Cairn API (0.1.0)

Download OpenAPI specification:

Cairn platform API — SINGLE SOURCE OF TRUTH for all HTTP contracts.

Rules (see repo CLAUDE.md):

  • Every payload change lands here FIRST, in the same PR as implementation.
  • Clients consume generated types only (TS via openapi-typescript, PHP DTOs via spatie/laravel-data generator).
  • Client-initiated POST endpoints accept an Idempotency-Key header: a retry with the same key within 24h replays the original success response instead of re-executing; a concurrent duplicate returns 409 idempotency_conflict. Deliberate exceptions (no header declared, enforced by the cairn-idempotency-key Spectral rule): webhook receivers under /hooks/* (senders carry their own event ids), /beacon (navigator.sendBeacon cannot set headers), /widget/broadcasting/auth (non-mutating handshake), the federation surfaces /sso/saml/* and /scim/v2/* (ATH-260 — the callers are a customer's IdP and its SCIM connector, neither of which is an Cairn client; for the SAML ACS a replayed success is precisely what the consumed-assertion store must refuse, and SCIM's own idempotency is the RFC 7644 §3.3 userName uniqueness 409), and the token-minting /auth/token, /widget/session + /oauth/token (responses carry credentials and are never replay-cached). /oauth/token has a second reason: an authorization code is single-use and a refresh token rotates, so replaying a cached 200 for a repeated request would hand back a credential the reuse-detection path exists to revoke. Multipart upload endpoints accept the header but implement chunk-level idempotency instead of response replay.
  • Pagination is cursor-based everywhere (cursor, limit, response meta.next_cursor).
  • Errors always use the Error envelope below.

Public vs internal (ATH-250, Doc 06 §4)

This document describes ONE /v1 namespace served by two kinds of credential, and they are not interchangeable:

  • Internal operations back Cairn's own portal and clients. They require a session or a user PAT. A workspace API key cannot call them and gets 401.
  • Public operations are the supported integration surface. They are tagged PublicApi IN ADDITION to their resource tag, they are authenticated by a workspace API key, and each one names the ability the key must carry. A key without that ability gets 403.

The PublicApi tag is the only marker; a path prefix is NOT one (both kinds live under /v1). Server-side the boundary is the public-api:<ability> middleware, and a route-enumeration test asserts the two agree in both directions — a public operation with no such route fails the build, and a route on the public surface that this spec does not tag PublicApi fails it too.

Abilities are <resource>:<read|write>, derived from the RBAC permission catalog (ApiAbilities). write does not imply read. A key may hold * for the whole surface.

Versioning and compatibility promise

/v1 is a stable major version. Within it we make only ADDITIVE changes: new endpoints, new OPTIONAL request fields, new response fields, and new members of a response enum.

In exchange, clients MUST ignore response fields they do not recognise and MUST tolerate unseen enum values. A client that rejects unknown fields is not compatible with this promise.

Anything else — removing or renaming a field, making an optional field required, removing an enum member, changing the status code for an existing condition, or changing what an existing field means — ships as /v2, and /v1 keeps working.

Retirement: an operation being withdrawn is marked deprecated: true here AND returns RFC 8594 Deprecation: and Sunset: headers, with at least 180 days between the two dates. A whole major version gets at least 365 days' notice. Every public response carries Cairn-Api-Version: v1.

PublicApi

ATH-250, Doc 06 §4 — the SUPPORTED integration surface. An operation carrying this tag (always alongside its resource tag) is callable with a workspace API key and names the ability that key must hold; everything in this document without it is internal and rejects key authentication outright. This tag is the reader's answer to "may I build on this?", and it is load-bearing rather than decorative: a Pest test enumerates the registered routes and fails the build if the set of PublicApi-tagged operations and the set of routes behind the public-api middleware ever disagree. See info.description for the versioning promise that comes with the tag.

List contacts (ability: contacts:read)

ATH-250 — cursor-paginated over ULID id descending (newest first). The cursor is an opaque id; paginating on a non-nullable, monotonic, unique column is what keeps a page boundary stable while rows are being written underneath it, which a timestamp cursor cannot promise.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
email
string <email>

Exact match, case-insensitive. The dedupe key an integration syncs on.

updated_since
string <date-time>

Only contacts seen at or after this instant — the incremental-sync filter.

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Create or upsert a contact by email (ability: contacts:write)

ATH-250 — upsert semantics on email, which is the workspace's unique key for a contact. An integration replaying its backlog must not create duplicates, and asking every caller to GET before POST would make that a race rather than a guarantee; a repeat POST therefore updates and returns 200, a first POST creates and returns 201. Blank incoming values never clobber stored ones, matching the CSV import's rule. Subject to the plan's contact ceiling (422 when full).

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
email
required
string <email>
name
string or null
phone
string or null
external_id
string or null

The caller's own id for this person — what a CRM sync joins on.

object

Freeform scalar custom attributes, max 30 keys.

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "name": "string",
  • "phone": "string",
  • "external_id": "string",
  • "attributes": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Read one contact (ability: contacts:read)

ATH-250 — a contact in another workspace is 404, never 403: a 403 would confirm the id exists somewhere, which is a cross-tenant disclosure however small.

Authorizations:
bearerAuth
path Parameters
contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a contact (ability: contacts:write)

ATH-250 — partial update; omitted fields are left alone. email is not updatable here: it is the dedupe key, so changing it would silently merge or split identities. Re-POST to /contacts to upsert against a different email.

Authorizations:
bearerAuth
path Parameters
contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string or null
phone
string or null
external_id
string or null
object

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "phone": "string",
  • "external_id": "string",
  • "attributes": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List conversations (ability: conversations:read)

ATH-250 — cursor-paginated over ULID id descending, same contract as /contacts. Read-only in v1: REPLYING through the public API sends a message to a real customer under the workspace's name, and that deserves its own ticket with the channel-window rules (WhatsApp's 24h, ATH-180) and the suppression list wired in, rather than being smuggled in behind a parity checkbox here.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
state
string
Enum: "pending" "open" "snoozed" "resolved"
channel
string
contact_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: contact_id=01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Read one conversation with its thread (ability: conversations:read)

ATH-250 — the conversation plus its messages, oldest first. Internal notes (direction=note) are EXCLUDED: they are staff commentary written in the belief that the customer will never see them, and an integration token is not staff. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
message_limit
integer [ 1 .. 200 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

List subscribable event types (ability: webhooks:read)

ATH-253 — the deliverable event catalog, derived at runtime from the events that implement PubliclyDeliverable (ATH-251). An integration builder renders this as the event picker, so a name that is not here cannot be subscribed to and a name that is here is guaranteed to be able to arrive.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

List this workspace's webhook endpoints (ability: webhooks:read)

ATH-253 — cursor-paginated, newest first. An iPaaS platform needs this to RECONCILE: a Zap deleted while Cairn was unreachable leaves an endpoint nobody will ever unsubscribe, and without a list call the only way to find it is the portal. The signing secret is never included — it is returned exactly once, by the create call.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Subscribe a URL to events (ability: webhooks:write)

ATH-253 — the subscribe half of a REST hook. Creates an ATH-251 endpoint and its subscriptions in one call and returns the HMAC signing secret EXACTLY ONCE, in this response body. A trigger implementation is expected to keep that secret with the subscription and verify Cairn-Signature on every delivery; there is no reveal endpoint, because "show me the secret again" and "rotate the secret" are the same operation from a security standpoint and only one of them leaves the existing verifier working.

url is validated by ATH-202's DNS-free SSRF guard here and by the resolving guard on every delivery attempt.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
required
string <= 2048 characters

An http(s) URL that is not loopback, link-local, private, cloud-metadata, or an internal suffix. Validated without DNS at authoring time and with DNS on every delivery attempt.

description
string or null <= 200 characters

Free text. An iPaaS client should identify itself and the automation here.

events
required
Array of strings [ 1 .. 50 ] items

Event type names from /webhook-event-types. An unknown name is a 422.

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "description": "string",
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Unsubscribe and delete an endpoint (ability: webhooks:write)

ATH-253 — the unsubscribe half of a REST hook. Subscriptions, deliveries and attempts cascade. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
webhookId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Hybrid retrieval over the workspace knowledge corpus

ATH-258 — vector + FTS retrieval fused by RRF (the ATH-154 engine) for non-MCP consumers and the Cairn MCP server. Requires a workspace API key with the retrieval:query ability; the key names the tenant, so results can never cross workspaces. Audience governance (Doc 07 §5): keys see public + customers chunks; internal chunks require the retrieval:internal ability and are silently excluded otherwise — requesting an audience the key may not see narrows to the permitted set (fail closed), it never errors content into view.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
query
required
string [ 2 .. 500 ] characters
top_k
integer [ 1 .. 25 ]
Default: 8
object

Responses

Request samples

Content type
application/json
{
  • "query": "string",
  • "top_k": 8,
  • "filters": {
    }
}

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Full published guide behind a retrieval hit (API-key principal)

ATH-258 — the document read behind a guide:{id} hit, for MCP cairn_get_guide. Requires a workspace API key with the guides:read ability. Published guides only, and only those whose share-mode audience the key may see (private guides map to internal and need retrieval:internal); anything else is 404 — existence is never confirmed across the audience fence.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Full published article behind a retrieval hit (API-key principal)

ATH-258 — the document read behind an article:{id} hit, for MCP cairn_get_article. Requires a workspace API key with the articles:read ability. Published articles only, and only those whose access-level audience the key may see (internal access needs retrieval:internal); anything else is 404 — existence is never confirmed across the audience fence.

Authorizations:
bearerAuth
path Parameters
articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Auth

Exchange credentials for a personal access token (PAT)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
Request Body schema: application/json
required
email
required
string <email>
password
required
string <password>
device_name
required
string <= 100 characters
totp_code
string

Required when 2FA is enabled

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "password": "pa$$word",
  • "device_name": "string",
  • "totp_code": "string"
}

Response samples

Content type
application/json
{
  • "token": "string"
}

Current authenticated user

When the session is an impersonation (ATH-034), impersonation is present — clients MUST render the banner from it.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "impersonation": {
    }
}

OAuthApps

ATH-252 — the OAuth app platform (Doc 06 §4). A third-party developer registers an app against their own workspace; a workspace ADMIN elsewhere grants that app scoped access to THEIR workspace through an authorization-code flow with mandatory PKCE.

Scopes are not a new vocabulary. An OAuth scope IS an ATH-250 ability (ApiAbilities), so an app's access is described in the same words as an API key's and enforced by the same public-api:<ability> middleware. A second, drifting scope list was the main design risk of this ticket and it was refused.

Two grant ceilings apply to every consent, and the SMALLER of them wins. The granted set may never exceed the app's registered request, and it may never exceed what the granting user could do themselves — an admin cannot delegate an ability they do not hold.

/oauth/token is the authorization server, not a resource: it is the one path here that carries no ability and no bearer token, because it is what MINTS them. It authenticates the client by client_id + client_secret + PKCE verifier and answers nothing to an API key. There is deliberately NO introspection endpoint — the token response already carries scope and workspace_id, so an ability-free authenticated GET would add a hole in ATH-250's route-enumeration invariant and buy the app nothing it was not handed at issue time.

Authorization server — exchange a code, or rotate a refresh token

ATH-252 — the ONLY path in this spec that carries no ability and no bearer token, because it is what mints them. It authenticates the CLIENT (client_id + client_secret) and, for the authorization-code grant, the PKCE code_verifier. An Cairn API key presented here is ignored and the request still fails: this endpoint answers nothing to a credential it did not issue.

PKCE is MANDATORY, not negotiable. code_challenge_method must be S256 at the authorize step and a matching code_verifier must arrive here; there is no plain method and no path that skips the check, so a downgrade attempt is a 400 rather than a weaker flow.

An authorization code is SINGLE USE and short-lived. Replaying a consumed code fails AND revokes the access token that code already issued, because a replay means the code leaked and the first exchange may not have been the legitimate one.

Refresh tokens ROTATE: every refresh returns a new refresh token and consumes the presented one. Presenting an already-consumed refresh token is treated as theft and revokes the app's entire token family for that workspace — the standard OAuth 2.1 / RFC 6819 §5.2.2.3 response, and the reason rotation is worth having at all.

Errors follow RFC 6749 §5.2 (error + error_description) rather than the house error.code envelope. That is a deliberate exception: every off-the-shelf OAuth client library parses this shape, and an authorization server that invents its own error format is one that integrators have to hand-write a client for.

Request Body schema:
required
grant_type
required
string
Enum: "authorization_code" "refresh_token"
client_id
required
string
client_secret
string
code
string

Authorization-code grant only. Single use, short TTL.

redirect_uri
string <uri>

Authorization-code grant only. Must equal the URI the code was issued against, exactly.

code_verifier
string

Authorization-code grant only, and MANDATORY — the PKCE verifier whose S256 hash must equal the stored challenge.

refresh_token
string

Refresh grant only. Rotated on every use.

Responses

Request samples

Content type
No sample

Response samples

Content type
application/json
{
  • "access_token": "string",
  • "token_type": "Bearer",
  • "expires_in": 0,
  • "refresh_token": "string",
  • "scope": "string",
  • "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}

RFC 7662 token introspection — the connection test for an iPaaS client

ATH-253 — Zapier and n8n both need a "is this connection still good?" call they can make on a schedule. ATH-250 deferred a GET /v1/token for that, because an ability-free endpoint that answers 2xx to a bearer token is precisely what its route-enumeration invariant exists to forbid, and excluding one would have made the exclusion list the real boundary.

This endpoint is not that. It is RFC 7662 introspection, and the RFC's own design is what resolves the tension: the caller authenticates as the CLIENT (client_id + client_secret) and passes the token it is asking about as a PARAMETER. A bearer token is not a credential here, exactly as at /oauth/token. So this route is swept by PublicApiSurfaceTest like every other api/v1 route, answers 401 to the live wildcard API key the sweep fires at it, and needs no exclusion and no ability. The invariant is untouched because nothing ability-free was added to it.

Per RFC 7662 §2.2 an inactive, expired, revoked or unknown token is {"active": false} with status 200 — NOT an error. A client must branch on active, never on the status code. Nothing beyond active is returned in that case, so this endpoint cannot be used to probe which token strings exist.

Scoping: a client may only introspect a token it was itself issued. Asking about another app's token answers {"active": false} — the same answer as a token that does not exist, because distinguishing the two would turn this into an oracle for other apps' credentials.

Request Body schema:
required
token
required
string

The access token being asked about.

token_type_hint
string
Value: "access_token"

RFC 7662 §2.1 permits this hint and permits a server to ignore it. Cairn introspects access tokens only; a refresh token presented here is inactive, because a client that can still refresh does not need to ask.

client_id
required
string
client_secret
required
string

Responses

Request samples

Content type
No sample

Response samples

Content type
application/json
{
  • "active": true,
  • "scope": "string",
  • "client_id": "string",
  • "token_type": "Bearer",
  • "exp": 0,
  • "iat": 0,
  • "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "workspace_name": "string"
}

Apps this workspace has REGISTERED as a developer

ATH-252 — requires integrations.manage. These are the apps the workspace OWNS and publishes, not the apps installed into it (that is /oauth-installs). The client secret existed once, in the create response, and is not recoverable from here or from anywhere else.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Register an app — client secret returned ONCE

ATH-252 — requires integrations.manage. redirect_uris are matched EXACTLY at authorize time: no prefix matching, no wildcards, no trailing-slash forgiveness. A near miss is a refusal, because prefix matching on a redirect URI is how an open redirect turns into a stolen authorization code.

requested_abilities is the app's CEILING, drawn from the ATH-250 vocabulary (ApiAbilities::all()); unknown abilities are rejected 422 at registration rather than silently granting nothing later. No consent may exceed this set, and no consent may exceed the granting user's own permissions either.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string <= 120 characters
description
string or null <= 500 characters
redirect_uris
required
Array of strings <uri> non-empty [ items <uri > ]

Absolute https URIs (http is accepted only for localhost, for local development). Matched byte for byte at authorize time.

requested_abilities
required
Array of strings non-empty

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "redirect_uris": [],
  • "requested_abilities": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    },
  • "client_secret": "string"
}

Withdraw an app — kills every install and token it holds

ATH-252 — requires integrations.manage, and only the OWNING workspace may call it. Withdrawing an app revokes every install of it in every workspace and every token those installs hold, immediately: a developer shutting down an integration should not leave live credentials pointed at their customers.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

appId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Third-party apps installed INTO this workspace

ATH-252 — requires integrations.manage. The other side of /oauth-apps: what this workspace has granted to somebody else's app, in the ability vocabulary the consent screen used, so "what can this integration actually do" has an answer that does not require reading code.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Revoke an install — its tokens die on the next request

ATH-252 — requires integrations.manage. Sets revoked_at on the install AND on every access token it issued, so access ends immediately rather than at the next token expiry. The rows are kept rather than deleted for the same reason a revoked API key's row is kept: last_used_at is what an incident review reads.

Idempotent — revoking an already-revoked install returns the same 200 with the original timestamp, because the caller's intent is already satisfied and a 409 would only encourage retry loops in the one situation where speed matters.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

installId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Workspaces

List workspaces the caller belongs to

Authenticated (session or API key); any member of the workspace may call it.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Create a workspace

ATH-018 — authenticated; the caller becomes the new workspace's owner. Pre-context (no workspace to authorize against yet). Idempotency-Key replays the same workspace for 24h.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 2 .. 80 ] characters

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Read a single workspace

ATH-018 — any member of the workspace; membership is enforced by workspace.context before the handler runs.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a workspace's name or settings (settings.manage)

ATH-018 — requires settings.manage. settings MERGES rather than replaces, so a partial update never clobbers keys it did not send.

PA-270 — that merge is DEEP, at every level. Objects are merged key by key: {"voice":{"recording_enabled":false}} changes that one key and leaves voice.recording_announcement and voice.greeting exactly as they were. (Until PA-270 it merged only the top level, so the same request deleted the rest of the voice object silently.)

ARRAYS ARE REPLACED WHOLESALE, never merged element by element — that is what lets a list SHRINK. Send the list you want stored; send [] to empty it.

AN EMPTY OBJECT NAMES NO KEYS, so {"voice":{}} changes nothing under voice (and {"settings":{}} changes nothing at all).

TO REMOVE A SETTING, SEND null. The key is stored as null and every consumer reads a stored null exactly as it reads an absent key — the platform default applies. {"voice":{"greeting":null}} drops the custom greeting and keeps its siblings; {"voice":null} retires the whole area.

PA-266 — settings.automation.sensitive_keywords is the workspace's own addition to the sensitive-page rail (Doc 07 §2.2, "no execution on payment/password pages"). Each entry is a substring matched case-insensitively against the host, path, query and fragment of any URL the "do it for me" engine is asked to act on; a match refuses the run. Use it to name the money and credential pages of your own product that the built-in English list misses — copay, nomina, paye, wire-transfer.

This list can only ever WIDEN what is refused. The built-in keywords (checkout, payment, billing, login, password, …) are always enforced on top of it and cannot be removed, overridden or allow-listed by any value stored here.

Stored canonically: entries are lowercased, trimmed and de-duplicated on write. Rejected with 422: a non-array, a non-string entry, an entry shorter than 2 or longer than 64 characters (a 1-character keyword would match nearly every URL and silently disable the engine), or more than 50 entries.

PA-268 — settings.tools.store_action_payloads decides whether the tool audit (agent_actions, Doc 05 §5) keeps the ARGUMENTS a tool was called with and the RESULT it returned. Default true: every existing workspace keeps the behaviour it has today. Set it to false and those two payloads are never written — the invocation record itself (who asked, which tool, what the permission engine decided, who approved, how it ended, how long it took, when) is written in full either way, and the row is stamped payload_scrub_reason: workspace_opt_out so a reader can tell "this workspace does not store payloads" from "the tool returned nothing".

It covers BOTH arguments and result, deliberately: an argument carries the same customer data a result does (the address in a create-order call), and it is the same pair the retention sweep drops. A control that kept one of them would be a privacy switch with a hole in it.

Rejected with 422: anything that is not a boolean — "true", "off", 7, an object. true/false, 1/0 and "1"/"0" are accepted and STORED AS A REAL BOOLEAN, because the reader compares strictly (=== false) and a stored "0" would otherwise be a setting you can save and cannot trust. Send null to retire the key back to the default.

PA-279 — settings.capture.strict_originals makes newly uploaded screenshot originals application-encrypted before object storage and queues encryption of existing originals. Enabling requires the Enterprise enforced-redaction entitlement and the default-off feature.capture.strict_originals rollout flag. Once enabled, a later flag rollback or plan downgrade does not silently weaken the stored privacy choice; send false explicitly to stop encrypting future originals. Owner/admin only through this route's existing settings.manage gate.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
name
string
object

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "settings": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Activation checklist derived from the domain-event spine

Steps complete when their domain event fires for the workspace (Doc 01 §7, ATH-052).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Hide the checklist for the workspace

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The workspace's brand kits

ATH-083 — logo, colors, marker style, font; applied at render/export time (Doc 03 §2).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a brand kit

Setting is_default clears the previous default (ATH-083).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
string <= 120 characters
primary_color
string^#[0-9a-fA-F]{6}$
secondary_color
string^#[0-9a-fA-F]{6}$
marker_style
string
Enum: "ring" "dot" "badge"
font
string or null <= 120 characters
logo_media_id
string or null
is_default
boolean

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "primary_color": "string",
  • "secondary_color": "string",
  • "marker_style": "ring",
  • "font": "string",
  • "logo_media_id": "string",
  • "is_default": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a brand kit — restyles referencing guides without reprocessing

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

brandProfileId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
primary_color
string^#[0-9a-fA-F]{6}$
secondary_color
string^#[0-9a-fA-F]{6}$
marker_style
string
Enum: "ring" "dot" "badge"
font
string or null <= 120 characters
logo_media_id
string or null
is_default
boolean

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "primary_color": "string",
  • "secondary_color": "string",
  • "marker_style": "ring",
  • "font": "string",
  • "logo_media_id": "string",
  • "is_default": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a brand kit — referencing guides fall back to the workspace default

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

brandProfileId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Members

listMembers

Requires members.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Change a member's role or permission overrides

Requires members.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

memberId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
role
string (Role)
Enum: "owner" "admin" "content_manager" "creator" "support_agent" "analyst" "viewer"
object

Per-member grants/revokes on top of the role bundle

Responses

Request samples

Content type
application/json
{
  • "role": "owner",
  • "permissions": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

removeMember

Requires members.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

memberId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Invitations

createInvitation

Requires members.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
email
required
string <email>
role
required
string (Role)
Enum: "owner" "admin" "content_manager" "creator" "support_agent" "analyst" "viewer"

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "role": "owner"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Preview a pending invitation by its emailed token

Public — the token IS the credential. The join screen receives it from the emailed action URL, removes it from browser history after hydration, and posts it in this request body. This API endpoint never accepts the token in its URL. Lets the join screen show workspace + role before the invitee registers or logs in.

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
token
required
string

Responses

Request samples

Content type
application/json
{
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Accept an invitation and join its workspace

Caller must be authenticated and their email must match the invitee email. Idempotent per token — replays after success return 410.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
token
required
string

Responses

Request samples

Content type
application/json
{
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

ApiKeys

List the workspace's API keys (never the secret)

ATH-250 — requires api_keys.manage. Returns metadata only: the plaintext key existed once, in the create response, and is not recoverable from anywhere including here. key_prefix is what lets a human match a row to the key in their password manager. Revoked keys are included, because "which keys did we kill and when" is the question this list is most often opened to answer.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

createApiKey

Requires api_keys.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string
abilities
required
Array of strings

Abilities from the ATH-250 vocabulary (ApiAbilities::all()), e.g. ["contacts:read","conversations:read"]. Unknown abilities are rejected with 422 rather than stored — a key holding a typo would silently grant nothing and fail at call time instead of at issue time. The vocabulary is derived from the RBAC permission catalog, so it cannot name a capability the roles model does not have; the two exceptions are the retrieval surface's retrieval:query and retrieval:internal (ATH-258), which gate a surface with no catalog analogue. * grants everything the public API exposes.

expires_at
string <date-time>

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "abilities": [
    ],
  • "expires_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "data": {
    },
  • "plaintext_key": "string"
}

Revoke a key — takes effect on the next request

ATH-250 — requires api_keys.manage. Sets revoked_at; the row is kept rather than deleted so an incident review can still read last_used_at and answer "was it used before we killed it". Idempotent: revoking an already-revoked key returns the same 200 with the original timestamp, because the caller's intent ("this key must not work") is already satisfied and a 409 would only encourage retry loops during the one situation where speed matters.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

apiKeyId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Billing

Subscription state for the workspace's organization (billing.manage)

ATH-041 — the billing entity is the organization (Cashier customer); this reads plan, trial, and subscription status. ATH-042 adds the dunning/grace state: payment_failed means an invoice failed and Stripe is retrying, dunning_grace_ends_at is when access lapses if it never lands. Note this is NOT on_grace_period, which is Cashier's post-cancellation window.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Stripe-hosted invoice history (billing.manage)

ATH-046 — invoices come from Stripe, and so do the documents: hosted_url and pdf are Stripe-hosted links (Doc 01 §6.2 "Stripe-hosted invoice PDFs"). We never render an invoice ourselves. Returns an empty list — not an error — when the org has no Stripe customer yet, so the billing page renders on a fresh install.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Stripe billing portal session (billing.manage)

ATH-046 — payment methods and tax IDs are handled by Stripe's hosted billing portal, never by us. Card data must never touch this application: no card form, no PAN in a request body, no PCI surface. Returns the URL to redirect the owner to.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{}

Available AI credit top-up packs (billing.manage)

ATH-045 — the one-time packs a workspace can buy. purchasable is false until the founder provisions that pack's Stripe price; the UI should hide rather than offer a checkout that would 503.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Buy a credit top-up pack via one-time Checkout (billing.manage)

ATH-045 — returns a hosted Stripe Checkout URL (one-time payment, not a subscription). Credit is granted by the checkout.session.completed webhook once the session is actually PAID, never here — this endpoint only opens the till.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
pack
required
string

Responses

Request samples

Content type
application/json
{
  • "pack": "string"
}

Response samples

Content type
application/json
{}

Auto-recharge settings (billing.manage)

ATH-045 — threshold-triggered auto-recharge against the org's saved default payment method. card_on_file is false when there is nothing to charge off-session, in which case enabling this will silently never fire — surface that in the UI.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update auto-recharge settings (billing.manage)

ATH-045 — enabling requires both a threshold and a pack: a half-configured auto-recharge that silently never fires is worse than one that is plainly off.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
enabled
required
boolean
threshold_cents
integer or null [ 0 .. 100000 ]
pack
string or null

Responses

Request samples

Content type
application/json
{
  • "enabled": true,
  • "threshold_cents": 100000,
  • "pack": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Plan limit usage (billing.manage)

ATH-047 — per-limit usage against the plan's ceiling. exceeded is the read-only state a downgrade can produce: the data stays (nothing is ever deleted), but creation is blocked until the workspace is back under the ceiling or upgrades. limit: null means uncapped.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Seat usage and allowance (billing.manage)

ATH-043 — seats are per workspace: allowance = the plan's included seats + any extra seats purchased on the Stripe subscription. occupied counts members plus pending invitations, so a seat is held from the moment it is offered. allowance: null means uncapped (Enterprise custom terms).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Set the number of purchased extra seats (billing.manage)

ATH-043 — sets the extra-seat subscription item's quantity on Stripe, prorated. quantity is the ABSOLUTE number of extra seats wanted (not a delta), so a retried request is idempotent. Only plans with an extra-seat price support this (Scale); other plans get 422 and should upgrade instead. Cannot be set below the seats currently occupied beyond the included allowance.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
quantity
required
integer [ 0 .. 500 ]

Responses

Request samples

Content type
application/json
{
  • "quantity": 500
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Start a Stripe Checkout session to subscribe (billing.manage)

Requires billing.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
plan
required
string
Enum: "starter" "growth" "scale"

Responses

Request samples

Content type
application/json
{
  • "plan": "starter"
}

Response samples

Content type
application/json
{}

AI credit balance + recent ledger entries (billing.manage)

ATH-044 — the current AI-credit balance (from the append-only ledger), whether the plan is unlimited, and recent grants/debits for the billing UI and top-up upsell.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Stripe webhook receiver (ATH-044)

Signature-verified + idempotently deduped. Handles the billing lifecycle: invoice.paid (monthly AI-credit grant + clears dunning), invoice.payment_failed (dunning/grace), the customer.subscription.* mirror, and checkout.session.completed / async_payment_succeeded / async_payment_failed for one-time credit top-ups (ATH-016 — async methods settle after checkout). Not a client API.

Authorizations:
bearerAuth

Responses

Captures

Enforced capture rules — clients fetch at session start

Org-enforced redaction and domain rules (Doc 02 §3.1, §7, §8). Enforced entries cannot be overridden client-side. ATH-235 adds enforced_rules + policy_checksum (workspace redaction policy — Enterprise; empty/null below the plan).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Workspace redaction policy rules

ATH-235 — Doc 02 §7.3–7.4. Requires settings.manage. Readable on any plan so a downgraded workspace can still audit (and delete) its rules; ingest and the capture-policy fetch stop applying them the moment the entitlement lapses.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Add a redaction policy rule

ATH-235 — Doc 02 §7.3–7.4. Requires settings.manage and the Enterprise feature.security.enforced_redaction entitlement (422 below it, ATH-094 pattern). New rules apply to captures ingested from this point on; existing guides are not re-scanned.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
kind
required
string (RedactionRuleKind)
Enum: "category" "selector" "field_label"

category = auto-blur a Smart Blur detector category wherever the Doc 02 §7.2 bank finds it (placement lands with ATH-234); selector = blur the bounding box of any captured element whose recorded selector_candidates contain the pattern; field_label = blur any captured element whose accessible name/label matches the pattern as a case-insensitive regex — "blur any field labeled Patient" (Doc 02 §7.4).

pattern
string or null <= 500 characters

Required for selector/field_label kinds; must compile as a regex for field_label

RedactionCategory (string) or null

Required for the category kind

enforced
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "kind": "category",
  • "pattern": "string",
  • "category": "pii.email",
  • "enforced": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a redaction policy rule

ATH-235 — partial update; kind/pattern/category are re-validated together. Requires settings.manage + the Enterprise entitlement (422 below it). Already-applied regions keep their captured shape; the change affects future ingests.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ruleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
kind
string (RedactionRuleKind)
Enum: "category" "selector" "field_label"

category = auto-blur a Smart Blur detector category wherever the Doc 02 §7.2 bank finds it (placement lands with ATH-234); selector = blur the bounding box of any captured element whose recorded selector_candidates contain the pattern; field_label = blur any captured element whose accessible name/label matches the pattern as a case-insensitive regex — "blur any field labeled Patient" (Doc 02 §7.4).

pattern
string or null <= 500 characters
RedactionCategory (string) or null
enforced
boolean

Responses

Request samples

Content type
application/json
{
  • "kind": "category",
  • "pattern": "string",
  • "category": "pii.email",
  • "enforced": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove a redaction policy rule

ATH-235 — allowed on any plan (cleanup after downgrade). Regions the rule already burned into guides remain: policy redactions are evidence, not reversible state.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ruleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

startCaptureSession

Requires guides.create. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
source
required
string
Enum: "extension" "desktop" "upload"
object (AppContext)
Ulid (string) or null

ATH-282 (Doc 07 §1 disposition "b") — the capture request this recording answers, when the session was opened from a capture-request deep link. Must reference an open or in-progress request in the SAME workspace; anything else is 422, which is what stops a deep link from tagging a recording onto another tenant's work order. Accepting it moves that request to in_progress, and the guide assembled at finalize is linked back to it — closing the loop to the gap cluster.

Responses

Request samples

Content type
application/json
{
  • "source": "extension",
  • "app_context": {},
  • "capture_request_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Batch-append steps (idempotent by (session, seq))

Requires guides.create. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
captureId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects (CaptureStep) <= 50 items

Responses

Request samples

Content type
application/json
{
  • "steps": [
    ]
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Finalize session and assemble the guide (idempotent)

ATH-047 — a workspace at its plan's guide ceiling gets 402 plan_limit_reached here, at the API boundary, rather than losing the capture inside the assembly job. Re-finalizing a session whose guide already exists always succeeds: the limit blocks new guides, and a replay creates none.

Authorizations:
bearerAuth
path Parameters
captureId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Guides

Current share state + public URL

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Set the guide's share mode (private / unlisted / public)

ATH-097 — requires the content-publish permission (sharing IS publishing to the world). public_id is minted on first share and stable forever after; unlisted pages are never indexed, public pages honor the indexable toggle. Only published guides actually serve — a draft with a share mode still 404s.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
mode
required
string
Enum: "private" "unlisted" "public"
indexable
boolean

Responses

Request samples

Content type
application/json
{
  • "mode": "private",
  • "indexable": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

listGuides

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "draft" "in_review" "published" "archived"
folder_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: folder_id=01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

getGuide

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Apply editor operations (autosave batch)

ATH-080 — typed editor operations applied in order and appended to the guide's operation log (undo + version snapshots, Doc 03 §2). The op vocabulary grows with the editor tickets (ATH-081+).

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects (GuideOp) [ 1 .. 50 ] items

Responses

Request samples

Content type
application/json
{
  • "ops": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Accept or dismiss Smart Blur suggestions in bulk

PA-277 — records a durable author decision for assisted Smart Blur regions on one screenshot. Accepted regions are applied to the derived image; dismissed regions stay excluded. Decisions survive detector re-runs. Requires guides.edit_any and feature.capture.smart_blur.

Authorizations:
bearerAuth
path Parameters
mediaId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
region_ids
required
Array of strings (Ulid) [ 1 .. 100 ] items unique [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ]
decision
required
string
Enum: "accept" "dismiss"

Responses

Request samples

Content type
application/json
{
  • "region_ids": [
    ],
  • "decision": "accept"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

AI assist — returns a suggestion; applying it goes through ops

ATH-085 — rewrite step / adjust tone / regenerate title+description / summarize (Doc 03 §2). Nothing persists here: the editor shows the suggestion and applies it via applyGuideOps, so every AI-assisted change is a logged, undoable operation.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
action
required
string
Enum: "rewrite_step" "adjust_tone" "regenerate_meta" "summarize"
step_id
string

Required for rewrite_step / adjust_tone

tone
string
Enum: "friendly" "formal" "concise" "enthusiastic"

Required for adjust_tone

Responses

Request samples

Content type
application/json
{
  • "action": "rewrite_step",
  • "step_id": "string",
  • "tone": "friendly"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Version history within the plan's retention window

ATH-086 — rows are never deleted; retention (Doc 03 §2.1) is a query-time window per plan, so an upgrade restores older history.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Step-level diff of a version against the current guide

Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

number
required
integer

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Render a step range as an animated GIF (async)

ATH-084 — returns a media id immediately; a queued job renders the frames (edited > annotated > raw screenshot variants). Poll getGuideGif until ready.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
from_seq
required
integer >= 1
to_seq
required
integer >= 1

Responses

Request samples

Content type
application/json
{
  • "from_seq": 1,
  • "to_seq": 1
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Poll a GIF render

Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

mediaId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{}

Acquire or heartbeat the editing soft lock (force = takeover)

ATH-087 — one editor per guide (Doc 03 §2.1). Locks go stale after 90s without a heartbeat; takeover is always available. 409 carries the current holder.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
force
boolean

Responses

Request samples

Content type
application/json
{
  • "force": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Release the caller's lock (no-op if not the holder)

Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Comments on a guide (optionally step-anchored)

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Comment on a guide or one of its steps

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
body
required
string <= 5000 characters
step_id
string or null

Responses

Request samples

Content type
application/json
{
  • "body": "string",
  • "step_id": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Resolve or reopen a comment (author or editor)

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

commentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
resolved
required
boolean

Responses

Request samples

Content type
application/json
{
  • "resolved": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a comment (author or editor)

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

commentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Upload a replacement screenshot for the editor

ATH-082 — sha256 content-addressed like capture media; a re-upload dedupes to the existing asset. Referenced by step.replace_screenshot.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: multipart/form-data
required
file
required
string <binary>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Media

Permanently delete a screenshot original in strict mode

PA-279 (Doc 02 §7.1) — requires settings.security, a workspace with settings.capture.strict_originals = true, and a screenshot that already has an applied redaction rendered into its edited derivative. The operation permanently deletes the encrypted raw object and every unredacted derivative (annotated + thumbnails), retaining only the redacted edited image. It is intentionally irreversible: future crop/blur or audience-profile changes cannot be re-rendered after deletion.

Idempotent: deleting an already-deleted original returns the same state and original timestamp. A foreign-tenant media id is 404. 404 below the default-off feature.capture.strict_originals flag unless this workspace already enabled strict mode (a rollout rollback never strands an existing privacy control). 409 means no applied redacted derivative exists yet; apply a blur and wait for rendering before retrying. 422 means the media is not a screenshot or strict mode is not enabled for this workspace.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

mediaId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Upload a screenshot or audio asset for a capture session

Multipart upload, retry/resume-safe via content addressing: send the client-computed sha256 and a re-upload of the same bytes returns the existing asset instead of storing a duplicate. Chunked/tus-style resumable transfer is deferred until the desktop app needs it (Doc 08 §7 open decisions) — extension screenshots are small.

Authorizations:
bearerAuth
path Parameters
captureId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: multipart/form-data
required
kind
required
string
Enum: "screenshot" "audio"
file
required
string <binary>
sha256
string

Client-computed content hash (hex); enables idempotent retries

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Upload a screenshot from a phone via a signed mobile-upload link

PA-184 (Doc 02 §5) — the phone's upload endpoint. NOT member-authed: the signed URL minted by createCaptureMobileUploadLink IS the credential (security: []). Laravel's signed middleware validates the HMAC + expiry before the controller runs, so a tampered id, an expired link, or a signature minted for another session/tenant is refused (403) — an upload can only ever land in the token's own capture session and workspace. The image flows through the SAME content-addressed media pipeline as uploadCaptureMedia (client sha256 → dedup) and is attached to the session as a screenshot step. Only images are accepted, mime + size enforced (422). 404 below feature.capture.mobile_upload; 409 once the session is no longer recording.

path Parameters
captureId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: multipart/form-data
required
file
required
string <binary>
sha256
string

Client-computed content hash (hex); enables idempotent retries

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Admin

AI margin dashboard — cost vs revenue (platform.admin)

ATH-102 — provider cost (llm_calls.cost_micros) vs credit revenue (agent debits), globally + by task + top workspaces.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Search tenants across the platform

Matches workspace name/slug substrings or an exact ULID. Includes suspended and soft-deleted tenants.

Authorizations:
bearerAuth
query Parameters
q
string
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Override a tenant's plan

Sets plan_id directly (comps, sales overrides). Purges the tenant's plan-derived feature-flag cache — ad-hoc flag targeting on those flags must be re-applied afterwards.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
plan
required
string
Enum: "free" "starter" "growth" "scale" "enterprise"

Responses

Request samples

Content type
application/json
{
  • "plan": "free"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Soft-delete a tenant

Members lose access immediately (context resolution 404s); data is retained for recovery.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Suspend a tenant (abuse/spam control)

Members are blocked (403) at workspace-context resolution until unsuspended.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Lift a tenant suspension

Super-admin only (platform.admin); a workspace role can never grant it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Grant AI credits to a tenant

Adds a positive, append-only credit-ledger entry. The required Idempotency-Key is also used as the ledger reference, so retries cannot grant credits twice even after the HTTP replay cache expires.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
amount_cents
required
integer [ 1 .. 100000000 ]
note
string or null <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "amount_cents": 1,
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Force a feature flag on/off for one tenant

Super-admin only (platform.admin); a workspace role can never grant it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

feature
required
string

A defined Pennant flag name (feature..)

Request Body schema: application/json
required
active
required
boolean

Responses

Request samples

Content type
application/json
{
  • "active": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Clear an override (revert to plan-derived resolution)

Super-admin only (platform.admin); a workspace role can never grant it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

feature
required
string

A defined Pennant flag name (feature..)

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Start impersonating a user

Issues a short-lived token that authenticates AS the target user (ATH-034). Starting a new impersonation revokes any previous one for the same user. Actions taken with the token are audit-logged as actor_type=super_admin with the target recorded in metadata. Super admins cannot be impersonated.

Authorizations:
bearerAuth
path Parameters
userId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Revoke a user's impersonation session immediately

Super-admin only (platform.admin); a workspace role can never grant it.

Authorizations:
bearerAuth
path Parameters
userId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Contacts

Search contacts (contacts.view)

ATH-137 — name/email substring search, most-recently-seen first, capped at 50.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
q
string <= 100 characters

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Contact profile with conversation history (contacts.view)

Requires contacts.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update profile fields and custom attributes (contacts.manage)

Requires contacts.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string or null <= 255 characters
phone
string or null <= 32 characters
object <= 30 properties

Freeform scalar custom attributes.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "phone": "string",
  • "attributes": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

CSV import with email dedupe (contacts.manage)

ATH-137 — header row required, email column mandatory; name/phone map to profile fields, other columns become custom attributes. Existing contacts (matched by email) update — blanks never clobber, attributes merge. Invalid emails are skipped and counted. Throttled 10/min. ATH-047 — if the workspace hits its plan's contact ceiling mid-file, the rows that fit still import and the rest come back as limit_skipped rather than failing the whole upload. Existing contacts keep updating even when over the limit.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
csv
required
string <= 1000000 characters

Responses

Request samples

Content type
application/json
{
  • "csv": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List contacts (ability: contacts:read)

ATH-250 — cursor-paginated over ULID id descending (newest first). The cursor is an opaque id; paginating on a non-nullable, monotonic, unique column is what keeps a page boundary stable while rows are being written underneath it, which a timestamp cursor cannot promise.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
email
string <email>

Exact match, case-insensitive. The dedupe key an integration syncs on.

updated_since
string <date-time>

Only contacts seen at or after this instant — the incremental-sync filter.

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Create or upsert a contact by email (ability: contacts:write)

ATH-250 — upsert semantics on email, which is the workspace's unique key for a contact. An integration replaying its backlog must not create duplicates, and asking every caller to GET before POST would make that a race rather than a guarantee; a repeat POST therefore updates and returns 200, a first POST creates and returns 201. Blank incoming values never clobber stored ones, matching the CSV import's rule. Subject to the plan's contact ceiling (422 when full).

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
email
required
string <email>
name
string or null
phone
string or null
external_id
string or null

The caller's own id for this person — what a CRM sync joins on.

object

Freeform scalar custom attributes, max 30 keys.

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "name": "string",
  • "phone": "string",
  • "external_id": "string",
  • "attributes": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Read one contact (ability: contacts:read)

ATH-250 — a contact in another workspace is 404, never 403: a 403 would confirm the id exists somewhere, which is a cross-tenant disclosure however small.

Authorizations:
bearerAuth
path Parameters
contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a contact (ability: contacts:write)

ATH-250 — partial update; omitted fields are left alone. email is not updatable here: it is the dedupe key, so changing it would silently merge or split identities. Re-POST to /contacts to upsert against a different email.

Authorizations:
bearerAuth
path Parameters
contactId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string or null
phone
string or null
external_id
string or null
object

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "phone": "string",
  • "external_id": "string",
  • "attributes": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Segments

Saved contact segments (Doc 04 §6, ATH-210): a boolean filter tree over contact attributes, activity, and channel facts. Membership is materialized (nightly recount + incremental per-contact refresh); dynamic previews evaluate live.

List saved segments with materialized counts (contacts.view)

ATH-210 — alphabetical. contact_count and refreshed_at come from the materialized snapshot (nightly recount + incremental per-contact refresh), not a live query.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a segment (contacts.manage)

ATH-210 — definition is validated as a filter AST (kind-by-kind; invalid trees 422 with node paths). Membership materializes via a queued job, so contact_count reads 0 until it lands — use the preview endpoint for an immediate count. Names are unique per workspace. Throttled 30/min.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string <= 120 characters
required
object (SegmentFilterNode)

ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on kind: and/or carry children; not carries child; attribute carries field/op/value; custom_attribute carries key/op/value (numeric ops compare only numerically-typed or numeric-string values); channel carries fact/value; last_seen and created carry op (within_days | not_within_days — the latter includes never-seen) and days; event_count carries event/min_count/within_days over contact_events; visited_page carries match (case-insensitive substring against payload.url of page.viewed contact_events) and within_days. The server validates the tree kind-by-kind and 422s with node paths; depth is capped at 6 and total nodes at 60.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "definition": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Live-evaluate a filter tree — count + sample (contacts.view)

ATH-210 — evaluates definition against the workspace's contacts NOW (no persistence): total match count plus a most-recently-seen sample of up to 10. Invalid trees 422 with node paths. Throttled 30/min — it runs real queries.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
object (SegmentFilterNode)

ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on kind: and/or carry children; not carries child; attribute carries field/op/value; custom_attribute carries key/op/value (numeric ops compare only numerically-typed or numeric-string values); channel carries fact/value; last_seen and created carry op (within_days | not_within_days — the latter includes never-seen) and days; event_count carries event/min_count/within_days over contact_events; visited_page carries match (case-insensitive substring against payload.url of page.viewed contact_events) and within_days. The server validates the tree kind-by-kind and 422s with node paths; depth is capped at 6 and total nodes at 60.

Responses

Request samples

Content type
application/json
{
  • "definition": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Segment with its filter definition (contacts.view)

Requires contacts.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

segmentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename or redefine a segment (contacts.manage)

ATH-210 — a changed definition re-validates the AST and queues a full re-materialization; contact_count reflects the OLD filter until the job lands.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

segmentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
object (SegmentFilterNode)

ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on kind: and/or carry children; not carries child; attribute carries field/op/value; custom_attribute carries key/op/value (numeric ops compare only numerically-typed or numeric-string values); channel carries fact/value; last_seen and created carry op (within_days | not_within_days — the latter includes never-seen) and days; event_count carries event/min_count/within_days over contact_events; visited_page carries match (case-insensitive substring against payload.url of page.viewed contact_events) and within_days. The server validates the tree kind-by-kind and 422s with node paths; depth is capped at 6 and total nodes at 60.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "definition": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a segment (contacts.manage)

Requires contacts.manage. Membership rows cascade and member contacts' cached segment lists are rewritten. Scoped to the workspace in the path.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

segmentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Campaigns

One-shot email campaigns to a segment (Doc 04 §6, ATH-211). The audience is a Segment's MATERIALIZED membership, never a live filter run. Sending fans out one queued send per contact through the ordinary email channel pipeline, and a per-recipient ledger row (unique per campaign+contact, claimed before the send) is what makes a redelivered job a no-op — a campaign never double-sends. Suppressed contacts (unsubscribed or hard-bounced) are excluded at fan-out AND re-checked at send time.

List campaigns, newest first (campaigns.manage)

ATH-211 — cursor-paginated, newest first. stats are the campaign's own counters, incremented as the fan-out runs, not a live aggregate over the recipient ledger.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "draft" "scheduled" "sending" "sent" "paused"

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Create a draft campaign (campaigns.manage)

ATH-211 — created in draft; nothing sends until the send endpoint is called. Requires the feature.messaging.campaigns plan entitlement (Growth and above) — without it every write 422s. segment_id must name a segment in the same workspace. Names are unique per workspace.

ATH-212 — channel: in_app additionally requires the feature.messaging.in_app_campaigns flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string <= 120 characters
channel
string
Default: "email"
Enum: "email" "in_app"

ATH-212 — how the campaign reaches its audience. email blasts through the mail pipeline the moment it is sent; in_app writes into each contact's widget conversation the next time that contact is present in the widget, so an in_app campaign stays sending for as long as audience members have yet to show up. Immutable once the campaign has left draft/scheduled — the audience is already claimed against one delivery mechanism.

in_app additionally requires the feature.messaging.in_app_campaigns flag to be active for the workspace; without it, writes 422 and nothing is delivered to the widget.

subject
required
string <= 255 characters
body
required
string <= 20000 characters

Plain-text body with merge fields. Syntax is deliberately small: {{ name }}, {{ email }}, {{ phone }} and {{ external_id }} read the contact's standard columns, and {{ attr.some_key }} reads a custom attribute. Unknown or empty fields render as an empty string — a campaign never leaks a raw {{ … }} into a customer's inbox. Nothing else is interpreted; the body is not a template language.

segment_id
string or null

The audience. Its MATERIALIZED membership is the recipient list, so a segment whose refresh has not landed yet sends to what the snapshot currently holds. Required before sending.

scheduled_at
string or null <date-time>

Set via the send endpoint; read-only here except to clear it.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "channel": "email",
  • "subject": "string",
  • "body": "string",
  • "segment_id": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One campaign with its counters (campaigns.manage)

Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

campaignId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Edit a draft or scheduled campaign (campaigns.manage)

ATH-211 — content and audience are editable only while the campaign is draft or scheduled; editing one that is sending or sent 422s, because recipients have already been claimed against the old content. status accepts paused (stop launching further recipients) and draft (un-schedule).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

campaignId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
channel
string
Default: "email"
Enum: "email" "in_app"

ATH-212 — how the campaign reaches its audience. email blasts through the mail pipeline the moment it is sent; in_app writes into each contact's widget conversation the next time that contact is present in the widget, so an in_app campaign stays sending for as long as audience members have yet to show up. Immutable once the campaign has left draft/scheduled — the audience is already claimed against one delivery mechanism.

in_app additionally requires the feature.messaging.in_app_campaigns flag to be active for the workspace; without it, writes 422 and nothing is delivered to the widget.

subject
string <= 255 characters
body
string <= 20000 characters

Plain-text body with merge fields. Syntax is deliberately small: {{ name }}, {{ email }}, {{ phone }} and {{ external_id }} read the contact's standard columns, and {{ attr.some_key }} reads a custom attribute. Unknown or empty fields render as an empty string — a campaign never leaks a raw {{ … }} into a customer's inbox. Nothing else is interpreted; the body is not a template language.

segment_id
string or null

The audience. Its MATERIALIZED membership is the recipient list, so a segment whose refresh has not landed yet sends to what the snapshot currently holds. Required before sending.

scheduled_at
string or null <date-time>

Set via the send endpoint; read-only here except to clear it.

status
string
Enum: "draft" "paused"

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "channel": "email",
  • "subject": "string",
  • "body": "string",
  • "segment_id": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "status": "draft"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a campaign that never sent (campaigns.manage)

ATH-211 — only draft and scheduled campaigns delete; one that is sending or has sent is a delivery record and 422s. The recipient ledger cascades with the row.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

campaignId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Send now, or schedule (campaigns.manage)

ATH-211 — with no body (or a null scheduled_at) the campaign moves to sending and the fan-out queues immediately; with a future scheduled_at it moves to scheduled and the every-minute due-campaign sweep launches it then — never earlier. Requires feature.messaging.campaigns and a segment_id.

ATH-212 — sending an in_app campaign resolves and freezes its audience the same way, but queues no delivery work: each recipient waits, claimed but pending, until that contact next appears in the widget. Calling send twice is safe: the transition out of draft / scheduled is a conditional update, so only one caller wins, and even a duplicated fan-out job cannot double-send because each recipient row is claimed before its send.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

campaignId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
scheduled_at
string or null <date-time>

Future instant to send at; null/omitted sends now.

Responses

Request samples

Content type
application/json
{
  • "scheduled_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Knowledge

RAG ingestion (Doc 05 §3): the sources the AI agent retrieves from — crawls, uploads, and the agent's own settings. Distinct from KnowledgeBase, which is the human-facing content.

List website crawls and their status (ai.configure)

Requires ai.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Start a website crawl (ai.configure)

ATH-152 — points the agent at a domain. The crawl runs on the queue (robots-aware, sitemap-seeded, depth/page capped) and its pages become public knowledge. Throttled 20/min.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
root_url
required
string <uri> <= 2048 characters
max_pages
integer [ 1 .. 2000 ]
Default: 100
max_depth
integer [ 0 .. 10 ]
Default: 3

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a crawl config (ai.configure)

Requires ai.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

crawlId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

List uploaded knowledge documents (ai.configure)

Requires ai.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Upload a document for ingestion (ai.configure)

ATH-153 — PDF/DOCX/MD/HTML/TXT up to 20 MB. The file is stored and an ingest job extracts, chunks, and embeds it. Throttled 60/min.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: multipart/form-data
required
file
required
string <binary>
title
string <= 255 characters
audience
string
Default: "public"
Enum: "public" "customers" "internal"

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete an uploaded document and its chunks (ai.configure)

Requires ai.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

sourceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Knowledge Health — gap clusters, drift, summary (articles.manage)

ATH-281 (Doc 07 §1) — the human-facing surface of the self-healing loop. Returns the workspace's open knowledge-gap clusters ranked by volume (data, cursor-paginated), the guides currently paused for selector drift with their step-health context (drifted_guides), and the triage summary counts (summary). Requires articles.manage; scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows. Gated on feature.knowledge.self-healing: 404 (not 403, so the flag leaks nothing) below a design-partner workspace. summary and drifted_guides accompany every page of the cluster cursor.

ATH-285 (Doc 07 §1 "ROI attribution") adds roi: deflected contacts over a trailing window, their modelled value, and the knowledge sources that earned the credit.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
roi_days
integer [ 1 .. 90 ]
Default: 30

ATH-285 — trailing window (days, ending now) the roi block is summed over. Default 30, max 90.

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ],
  • "summary": {
    },
  • "drifted_guides": [
    ],
  • "roi": {
    }
}

Triage a gap cluster — mark reviewed/dismissed (articles.manage)

ATH-281 (Doc 07 §1 disposition "c) dismiss") — records a triage disposition on a gap cluster so it drops out of the open Knowledge Health queue. Requires articles.manage. Idempotent: replaying the same disposition is a no-op returning the same cluster. The capture-request (ATH-282) and AI-draft (ATH-283) dispositions are owned by their own tickets and are not part of this endpoint.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

clusterId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
disposition
required
string
Enum: "reviewed" "dismissed"

reviewed = triaged and acknowledged; dismissed = intentionally ignored. Both remove the cluster from the open queue.

Responses

Request samples

Content type
application/json
{
  • "disposition": "reviewed"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

AI-draft an article from a gap cluster (articles.manage)

ATH-283 (Doc 07 §1 disposition "a) AI-draft article from existing partial knowledge") — queues a job that writes a DRAFT article for this gap-cluster theme and returns immediately. The draft is grounded in the cluster's member questions (what people actually asked) and in existing knowledge retrieved for the canonical question, so it reuses real facts and cites real sources rather than inventing them. It lands in the ordinary ATH-090 draft→review→publish workflow as status=draft for a human to edit and approve; nothing is published by this call.

Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag, matching the rest of the surface). Drafting spends AI credits: the response is 202 whether the job was newly queued or was already in flight, and a cluster that already has a pending or created draft is NEVER drafted twice — the repeat call returns the existing draft reference untouched. A workspace out of credits gets 402.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

clusterId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Turn a gap cluster into a capture request (articles.manage)

ATH-282 (Doc 07 §1 disposition "b) CAPTURE REQUEST → assignment") — the complement to AI-drafting. Some themes cannot be answered from existing knowledge because nobody has written the flow down yet; those need a human to RECORD it, not a model to paraphrase what isn't there. This creates a work order against the cluster, optionally assigned to a workspace member, and returns a deep link that opens the browser extension primed to record for it.

Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag, matching the rest of the surface). A cluster carries at most ONE live capture request: a second call while one is open or in progress returns that request with 200 instead of creating a duplicate work order, so two reviewers clicking the same row cannot assign the same recording twice. A dismissed request releases the theme, which may then be requested again.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

clusterId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
Ulid (string) or null

Workspace member to assign the recording to. Must be a member of this workspace; anything else is 422. Omit to create the request unassigned and assign it later.

title
string or null <= 200 characters

What to record. Defaults to the cluster's canonical question — the thing people actually kept asking.

Responses

Request samples

Content type
application/json
{
  • "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "title": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List capture requests (articles.manage)

ATH-282 (Doc 07 §1) — the workspace's capture work orders, newest first, cursor-paginated. Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "open" "in_progress" "completed" "dismissed"

Filter to one lifecycle state. Omit for every request in the workspace.

assignee_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: assignee_id=01j8me9fycv0q4t4c7wz8k2xt1

Filter to one assignee — the SME's own queue of recordings to make.

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Read one capture request (guides.create)

ATH-282 (Doc 07 §1) — reads a single work order. This is the endpoint the browser extension calls to VERIFY a capture-request deep link before it starts recording: the id arrives from a web page, which is untrusted input, so the extension resolves it against the API using the signed-in user's own token. A request belonging to another workspace is a 404 here, which is precisely what makes a forged or stale deep link inert.

Gated on guides.create rather than articles.manage: the assignee is an SME who records the flow, and requiring the content-maintenance permission would lock the deep link out for exactly the person it was sent to.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

requestId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Assign, dismiss or complete a capture request (articles.manage)

ATH-282 (Doc 07 §1) — moves a work order through its lifecycle. assignee_id (re)assigns it; status transitions it. Requires articles.manage and is gated on feature.knowledge.self-healing.

Transitions are one-way out of the terminal states: completed and dismissed requests reject further changes with 422, so a closed work order cannot be silently reopened or reassigned. Dismissing RELEASES the gap cluster, which becomes requestable again; completing links the resulting guide back to the cluster, which is how the loop closes. in_progress is normally reached by the extension starting a recording rather than by this endpoint.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

requestId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
non-empty
status
string
Enum: "open" "in_progress" "completed" "dismissed"
Ulid (string) or null

Workspace member to assign to; null unassigns.

Ulid (string) or null

The recorded guide that answers this request. Required when transitioning to completed by hand; the extension path sets it automatically at finalize.

Responses

Request samples

Content type
application/json
{
  • "status": "open",
  • "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Shadow-mode enablement report (ai.view_runs)

ATH-172 — how the AI would have handled real conversations in shadow mode, with AI-draft vs human-reply pairs to review before enabling the agent live.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The AI agent's configuration (ai.configure)

Requires ai.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update the AI agent's configuration (ai.configure)

ATH-164 — persona, custom instructions, the on/active/review mode, and a settings bag (answer length, blocked topics, languages, escalation rules).

PA-271 — settings MERGES on update, and here is what that means exactly, because the previous one-line claim was not the whole behaviour. A key you send replaces the stored value for that key; keys you omit are left alone, so a partial update never clobbers the rest of the bag. blocked_topics and languages are lists and are replaced WHOLESALE, never merged element-wise — send the list you want stored, and send [] to clear it. Objects, should this bag ever grow one, merge key-by-key at every depth, and null retires a key.

A key that is not in AgentSettings is REJECTED with 422 (it names the offending keys), not stored and not silently dropped. This is a deliberate behaviour change: before PA-271 such keys were discarded without any signal, so a typo'd key looked like a successful save that did nothing.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
persona
string or null <= 2000 characters
instructions
string or null <= 5000 characters
status
string
Enum: "inactive" "active" "review" "shadow"
object (AgentSettings)

The agent's tuning bag. These are the keys AgentSettingsController validates — the read and write shapes reference this same schema, so they cannot drift apart.

PA-271 — the bag is CLOSED (additionalProperties: false): on write, a key not listed here is rejected with 422 rather than stored or silently dropped. Unlike Workspace.settings, which is free-form by contract, every key here has a declared type and a reader, so an unknown one is a typo or a client built against a contract this server does not implement — both worth telling the caller about.

channels
Array of strings
Items Enum: "widget" "email"

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "persona": "string",
  • "instructions": "string",
  • "status": "inactive",
  • "settings": {
    },
  • "channels": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

KnowledgeBase

Help-centre content (Doc 03 §3–4): collections, articles, pages, and KB search. Distinct from Knowledge, which is the AI retrieval layer. ATH-224 — getHelpCenterSearchAnswer is the one endpoint here that is NOT served on the API hosts above. It is registered under the same api/v1 prefix, but domain-scoped to the help-centre hosts, because its tenant comes from that host alone — never from a bearer token, a workspace id, or a widget key.

KB categories (one level of nesting), ordered

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a category (children cannot themselves parent)

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
string <= 120 characters
parent_id
string or null
access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "parent_id": "string",
  • "access_level": "public",
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a category

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

collectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
parent_id
string or null
access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "parent_id": "string",
  • "access_level": "public",
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a category (articles fall back to uncategorized)

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

collectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Articles (filter by collection_id / status); bodies omitted

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
collection_id
string
status
string
Enum: "draft" "in_review" "published" "archived"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create an article (body sanitized server-side)

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
title
string <= 255 characters
body
string or null
collection_id
string or null
access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "body": "string",
  • "collection_id": "string",
  • "access_level": "public",
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Full article incl. body + divergence indicator

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update an article (body sanitized server-side)

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
title
string <= 255 characters
body
string or null
collection_id
string or null
access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "body": "string",
  • "collection_id": "string",
  • "access_level": "public",
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete an article

Requires articles.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Workflow transition — publishing requires approval permission

ATH-090 — "creator submits, content_manager approves" (Doc 03 §4): transitioning INTO published requires the content-publish permission and stamps approved_by + published_at.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
status
required
string
Enum: "draft" "in_review" "published" "archived"

Responses

Request samples

Content type
application/json
{
  • "status": "draft"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Per-locale variants of an article

ATH-242 — the locale variants of one article (Doc 03 §5 "Multilingual"). Requires articles.manage and the feature.content.multilingual flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403 and never another tenant's rows. Each variant carries its OWN publish state: a draft translation does not serve even when the source article is live.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create or replace the variant of an article in one locale

ATH-242 — idempotent per (article, locale): one article has at most one variant per locale, so this is a PUT rather than a POST and calling it twice leaves one row. Requires articles.manage and the feature.content.multilingual flag. Body HTML is sanitized with the same allowlist as article bodies. Recording a translation reconciles it against the current source body, which clears the stale flag. Creating never publishes — a new variant is always a draft and reaches readers only through the status endpoint. The default locale is refused with 422: the article itself IS the default locale and a variant of it would be a second source of truth for the same URL.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

locale
required
string <= 24 characters

A BCP-47 locale tag restricted to an ISO 639-1 language with an optional script and/or region — de, pt-BR, zh-Hans. Accepted in any casing and canonicalized on write. The language allowlist is closed because the public site serves non-default locales under a path prefix that shares the root namespace with ATH-240 pages.

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
title
required
string <= 255 characters
body
string or null
slug
string <= 160 characters

Optional — derived from the translated title when omitted. Unique per workspace PER LOCALE, so /a/pricing and /de/a/pricing may coexist.

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "body": "string",
  • "slug": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove the variant of an article in one locale

ATH-242 — requires articles.manage and the feature.content.multilingual flag. Removing a published variant also removes it from the hreflang set and the sitemap of every other locale, because both are computed from the live variants rather than stored.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

locale
required
string <= 24 characters

A BCP-47 locale tag restricted to an ISO 639-1 language with an optional script and/or region — de, pt-BR, zh-Hans. Accepted in any casing and canonicalized on write. The language allowlist is closed because the public site serves non-default locales under a path prefix that shares the root namespace with ATH-240 pages.

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Publish or unpublish ONE locale independently

ATH-242 — the independent-publish seam (Doc 03 §5). A locale is published on its own terms: publishing German does not touch the source article or any other locale, and the source article being live does not publish German. Transitioning INTO published requires the same content-publish permission articles use ("creator submits, content_manager approves") and stamps approved_by plus published_at. There is no archived state — a translation that should stop serving goes back to draft and the work is kept.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

locale
required
string <= 24 characters
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
status
required
string
Enum: "draft" "in_review" "published"

Responses

Request samples

Content type
application/json
{
  • "status": "draft"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Machine-translate an article into one locale, as an unpublished draft

ATH-225 — the AI translation workflow (Doc 03 §5 "AI-translate workflow: one click → gateway translates → human review state → publish per locale"; Doc 05 §7 "Multilingual"). Requires articles.manage plus BOTH feature.content.multilingual (the locale surface this writes into) and feature.ai.translation (permission to spend credits writing it); below either flag the route is 404.

THE RESULT IS NEVER PUBLISHED. It lands in in_review — the review state Doc 03 §5 names — so machine text cannot reach a visitor without a human with the content-publish permission transitioning it through the status endpoint. That human action IS the review; there is no path by which this endpoint puts unreviewed text on a public help centre.

A HUMAN'S EDITS ARE NEVER SILENTLY OVERWRITTEN. A translation whose origin is human responds 409 unless force is true, exactly as ATH-091's sync-from-guide refuses to destroy manual article edits. Re-translating a stale translation is therefore an explicit human act, not a background job — see stale.

Structure is verified rather than trusted: the model's output must preserve every code block and every placeholder token from the source. Output that does not is REJECTED (422) and the reason reported, never written and never silently repaired. Output below the confidence floor is rejected the same way. Every attempt — including refusals — appends a row to the translation's provenance history.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

locale
required
string <= 24 characters
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
force
boolean
Default: false

Overwrite a translation a human has edited. Default false, which is what makes a re-translation safe to run against a locale someone has corrected by hand.

Responses

Request samples

Content type
application/json
{
  • "force": false
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Linked update — regenerate the article body from the current guide

ATH-091 — update propagation for guide-generated articles (Doc 03 §4): re-renders the body from the guide's current steps and clears diverged_from_guide. If the article body was manually edited since generation, responds 409 unless force is true — a sync must never silently destroy human edits. Title/slug/collection/access/status are left untouched.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
force
boolean
Default: false

Overwrite manual body edits

Responses

Request samples

Content type
application/json
{
  • "force": false
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

👍/👎 + optional comment (one vote per submitter; re-vote updates)

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
rating
required
string
Enum: "up" "down"
comment
string or null <= 2000 characters

Responses

Request samples

Content type
application/json
{
  • "rating": "up",
  • "comment": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Pages (filter by status); blocks omitted

ATH-240 — standalone long-form pages on the help centre (Doc 03 §3). Requires pages.manage and the feature.content.pages flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
status
string
Enum: "draft" "in_review" "published" "archived"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a page (slug derived from the title unless given)

ATH-240 — requires pages.manage and the feature.content.pages flag. Blocks are validated per type server-side and rich text is sanitized at the write boundary. Slug uniqueness is per WORKSPACE and reserved help-centre path segments are refused with 422.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
title
string <= 255 characters
slug
string <= 160 characters

Optional — derived from the title when omitted. Lowercase alphanumerics and hyphens only. Unique per workspace and refused when it matches a reserved help-centre segment.

access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
Array of any (PageBlock) <= 200 items

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "slug": "string",
  • "access_level": "public",
  • "blocks": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Full page incl. blocks

ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pageId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a page (blocks revalidated and rich text resanitized)

ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pageId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
title
string <= 255 characters
slug
string <= 160 characters

Optional — derived from the title when omitted. Lowercase alphanumerics and hyphens only. Unique per workspace and refused when it matches a reserved help-centre segment.

access_level
string
Enum: "public" "unlisted" "authenticated" "internal"
Array of any (PageBlock) <= 200 items

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "slug": "string",
  • "access_level": "public",
  • "blocks": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a page

ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pageId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Workflow transition — publishing requires approval permission

ATH-240 — pages reuse the article workflow (Doc 03 §3 "the same publishing/permissions machinery"): "creator submits, content_manager approves", so transitioning INTO published additionally requires the content-publish permission and stamps approved_by + published_at. Only a published page renders on the public help centre; every other status 404s to visitors.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pageId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
status
required
string
Enum: "draft" "in_review" "published" "archived"

Responses

Request samples

Content type
application/json
{
  • "status": "draft"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Review schedules for the workspace's published knowledge

ATH-241 — verification workflows (Doc 03 §2.1). Requires content.verify and the feature.content.verification flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403. This is the review QUEUE: filter by status=due for the work a content manager has to do. Ordered by due_at ascending so the most overdue is first, and cursor-paginated because a workspace can put its whole help centre on a cadence. PA-253 — filter by unowned=true for content whose owner left the workspace. Ownership is nulled when a member is removed, and this is where that content stays visible so a null owner is surfaced rather than silent: it is orthogonal to status, so an in-date schedule with no owner still shows up here.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
status
string
Enum: "verified" "due" "expired"
content_type
string
Enum: "article" "page" "guide"
owner_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: owner_id=01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

unowned
boolean

PA-253 — when true, return only schedules with no owner (owner_id null), the state a departed owner leaves behind.

limit
integer [ 1 .. 100 ]
Default: 25
cursor
string

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

One review schedule plus its full attestation trail

ATH-241 — requires content.verify and the feature.content.verification flag. 404 when the content has no review schedule; the content itself must live in the workspace in the path.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page" "guide"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Put content on a review cadence (idempotent create-or-update)

ATH-241 — requires content.verify and the feature.content.verification flag. Creates the schedule when the content has none and updates the owner or cadence when it does. Creating a schedule counts as a first verification: it is stamped verified by the caller now and due_at becomes now plus interval_days. CHANGING the cadence later re-bases due_at on the last verification rather than on the present, so shortening an interval can make a review immediately due — which is the point of shortening it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page" "guide"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
interval_days
required
integer [ 1 .. 3650 ]
owner_id
string or null

Must be a member of this workspace.

Responses

Request samples

Content type
application/json
{
  • "interval_days": 1,
  • "owner_id": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Take content off its review cadence

ATH-241 — requires content.verify and the feature.content.verification flag. Removes the schedule so the sweep stops raising reviews. The attestation records go with it: the trail exists to support a live claim about live content, and keeping orphaned rows after the schedule is deliberately dropped would outlive the claim they document.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page" "guide"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Record that a human has re-read this content

ATH-241 — the re-verify action (Doc 03 §2.1). Requires content.verify and the feature.content.verification flag. Stamps the caller as verified_by, resets status to verified and pushes due_at out by interval_days from now. Appends an immutable record naming who attested and when; that trail is the feature, so the record is written in the same transaction as the status change and neither can land without the other.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page" "guide"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
note
string or null <= 2000 characters

Responses

Request samples

Content type
application/json
{
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Version history for an article or page, newest first

ATH-024 — snapshot history (Doc 03 §2.1). Requires guides.publish (the content-publish permission, which ArticleController and PageController already use to gate publishing) and the feature.content.versioning flag.

contentType is article or page and NOT guide: guides keep their own history at /guides/{guideId}/versions, on a separate table, because a guide's body is guide_steps rows rather than a column and Doc 03 §2.1 mandates a never-delete plan window for them that is incompatible with the storage cap here.

Retention is a hard cap of the newest N per content item (default 20). Beyond the cap the OLDEST snapshots are deleted; numbers are never reused, so a pruned version 404s rather than resolving to a different snapshot.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

One snapshot, with its full payload

ATH-024 — the stored snapshot as it was captured. Requires guides.publish and the feature.content.versioning flag.

The payload is what was captured, which is deliberately WIDER than what a restore puts back: it records status and access_level for the audit trail, and restore never reapplies either (see the restore operation).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

number
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Roll content back to a snapshot, or refuse

ATH-024 — restore (Doc 03 §2.1, §8 scenario 8). Requires guides.publish and the feature.content.versioning flag. Publish-level and not merely edit-level on purpose: one call replaces the body of content that may be live to the public, with no review step, so the role trusted to approve what goes live is the role trusted to roll it back.

WHAT IS RESTORED is the CONTENT only — title, body or blocks, and (for a page) the slug. status, published_at, approved_by and access_level are left exactly as they are, so a rollback can undo an authoring mistake but can never silently republish archived content or widen who may read it.

WHAT REFUSES: the snapshot is re-validated through the same code an ordinary write runs — block shape and referents for a page, HTML sanitization and collection existence for an article — and a snapshot that would no longer be accepted as a fresh write is rejected 422 with NOTHING written. A block type since retired, an embedded guide since deleted, an image since removed, a slug since taken by another page: each refuses loudly rather than persisting a body the renderer cannot draw onto a live public page.

Restoring is itself undoable: the state being overwritten is snapshotted first, in the same transaction.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "article" "page"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

number
required
integer >= 1
header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

KB search v1 — published articles by title/body

ATH-099 — Postgres FTS (tsvector + ts_rank) in production, LIKE fallback on other drivers; Meilisearch swaps in at P4 behind the same shape. Members see every published article regardless of access level (they are workspace members); the public help-center search applies visitor access rules instead.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
q
required
string [ 2 .. 200 ] characters

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

On-demand-TLS issuance authorization (ATH-093)

Called by the help-center ingress (Caddy on_demand_tls "ask" or a platform equivalent) before minting a certificate. 200 iff the hostname has a VERIFIED custom-domain row. Shared-secret token in the query because the ask directive sends no headers; the token unset disables the endpoint (fail closed). Not a client API.

query Parameters
domain
required
string
token
required
string

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

AI answer box for public help-center search (ATH-224)

ATH-224 (Doc 05 §7, Doc 03 §5) — a synthesized, cited answer over the workspace's PUBLIC published articles, shown above the ordinary link results on the hosted help centre. Served ONLY on the help-centre host — {slug}.cairnhelp.com or a verified ATH-093 custom domain — and the route is domain-scoped to those hosts. The tenant therefore comes from the host alone: there is no bearer token, no workspace id in the path, and no widget key, so there is no parameter a caller could change to reach another workspace's content. Retrieval is pinned to the public audience and every hit is re-verified against its live article row (published + public) before it may ground or be cited, so internal, unlisted, authenticated, and draft content can reach neither the answer nor the citations. Unauthenticated and LLM-backed, so it is the most abusable surface in the product: throttled per IP+host, query capped at 200 characters, answers cached per workspace under the help-centre content version, and metered against the workspace that owns the help centre (ATH-102/166). ADVISORY by contract: the link results come from the server-rendered /search page and never depend on this call. Flag off, plan below the AI suite, exhausted credits, and generation failures all return 200 with status: unavailable — a public site must never show a visitor a 402 because its owner ran out of credits.

query Parameters
q
required
string [ 2 .. 200 ] characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Help-center theme (logo, header links, custom CSS, white-label)

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update the help-center theme

ATH-094 — custom_css and white_label are Scale+ entitlements (feature.content.custom_css / feature.content.white_label): setting them on a lower plan is a 422. Colors and fonts come from brand profiles (ATH-083), not this endpoint.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
logo_media_id
string or null
Array of objects <= 8 items
custom_css
string or null <= 20000 characters
white_label
boolean

Responses

Request samples

Content type
application/json
{
  • "logo_media_id": "string",
  • "header_links": [
    ],
  • "custom_css": "string",
  • "white_label": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Custom help-center domains for the workspace

ATH-093 — Doc 03 §5. Requires settings.manage. Each row carries the cname_target tenants must point their CNAME at; status moves pending → verified/failed via the async DNS check.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Attach a custom domain (starts CNAME verification)

ATH-093 — Doc 03 §5. Requires settings.manage and the feature.content.custom_domain entitlement (Scale+; 422 below it). The hostname must be a valid FQDN, not on an Cairn-owned zone, and globally unused. Verification is asynchronous: the row returns pending and flips once the CNAME check runs.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
hostname
required
string

Fully-qualified hostname, e.g. help.customer.com. Lowercased on save.

Responses

Request samples

Content type
application/json
{
  • "hostname": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Detach a custom domain

ATH-093 — removing the row immediately stops host resolution and TLS-issuance authorization for the hostname.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

domainId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Re-run the CNAME verification check

ATH-093 — the doc's "verification check endpoint". Queues a fresh DNS check; poll listDomains for the outcome. Throttled.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

domainId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Help-center JWT handshake settings (shared secret)

ATH-096 — the customer's app signs visitor JWTs (HS256, exp required) with this per-workspace secret; the help center verifies them to unlock 'authenticated' articles. Readable by settings managers only.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Generate (or replace) the help-center JWT shared secret

ATH-096 — rotation invalidates every outstanding visitor JWT signed with the old secret immediately.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

One-click guide → linked KB article (the core Cairn glue)

ATH-090 — sections become headings, steps become numbered instructions with screenshots; the article stays linked to the guide and diverged_from_guide flips when the guide changes.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
collection_id
string or null

Responses

Request samples

Content type
application/json
{
  • "collection_id": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

The workspace's audiences (trio + custom)

PA-231 — Doc 07 §5. Requires helpcenter.manage and the feature.audiences.variants flag; below the flag every route in this group is 404. Returns the fixed trio (public, customers, internal) plus any Enterprise custom audiences, system rows first.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Add a custom audience

PA-231 — Doc 07 §5. Requires helpcenter.manage, the feature.audiences.variants flag, and the Enterprise feature.audiences.custom entitlement (422 below it, ATH-094 pattern). key may not collide with the reserved trio.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
key
required
string [ 2 .. 32 ] characters ^[a-z][a-z0-9_]*$

Stable slug; may not be one of the reserved trio keys.

name
required
string [ 1 .. 80 ] characters
retrieval_scope
required
string
Enum: "public" "customers" "internal"

The base scope a custom audience inherits; fails closed to internal.

Responses

Request samples

Content type
application/json
{
  • "key": "string",
  • "name": "string",
  • "retrieval_scope": "public"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename or re-scope a custom audience

PA-231 — partial update of a CUSTOM audience's name and base retrieval scope. System trio rows are immutable (422). Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

audienceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 80 ] characters
retrieval_scope
string
Enum: "public" "customers" "internal"

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "retrieval_scope": "public"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove a custom audience

PA-231 — deletes a CUSTOM audience; its publications cascade, so content is unpublished from it and that audience's chunks/media variants clear. System trio rows cannot be deleted (422). Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

audienceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

A content item's audience publications (a governance-matrix row)

PA-231 — Doc 07 §5. The per-audience variant matrix for one guide or article: which audiences it is published to, each variant's redaction profile and freshness SLA. Requires helpcenter.manage + the flag; an unknown content type is 404.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "guide" "article"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Publish content to an audience with a per-audience variant

PA-231 — Doc 07 §5. Idempotent by (content, audience): sets this audience's redaction profile (the Doc 02 §7 categories its media variant auto-blurs at render), per-audience verify_interval_days, and publish state, then re-indexes the audience's chunks and renders its blurred media variant. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "guide" "article"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

audienceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
redaction_profile
Array of strings (RedactionCategory)
Default: []
Items Enum: "pii.email" "pii.phone" "pii.ssn" "pii.card" "pii.iban" "pii.name" "pii.address" "secret.api_key"
verify_interval_days
integer or null [ 1 .. 3650 ]
locale_set
Array of strings or null
theme
object or null
published
boolean
Default: true

false unpublishes from the audience without deleting the row.

Responses

Request samples

Content type
application/json
{
  • "redaction_profile": [ ],
  • "verify_interval_days": 1,
  • "locale_set": [
    ],
  • "theme": { },
  • "published": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Unpublish content from an audience

PA-231 — removes the publication and clears that audience's chunks and media variant. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "guide" "article"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

audienceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

The content × audience governance matrix

PA-232 (ATH-315) — Doc 07 §5 governance console. One row per content item that carries any audience publication, one cell per audience: published?, redaction profile, translation freshness, verification status (computed against the per-audience verify_interval_days override so a shorter per-audience SLA can read due while the content-level schedule reads verified). Assembled in PHP — no JSON SQL predicates. Requires helpcenter.manage and the feature.audiences.governance flag; below the flag every route in this group is 404. Page-paginated (fetch limit+1 to detect the next page) because a governed corpus is bounded per workspace.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
content_type
string
Enum: "guide" "article"
limit
integer [ 1 .. 100 ]
Default: 25
page
integer >= 1
Default: 1

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Publish or unpublish many (content × audience) pairs at once

PA-232 (ATH-315) — Doc 07 §5 bulk operations. Each operation is applied through the SAME per-audience publish path as the single PA-231 endpoint (SyncAudiencePublications reconciles the corpus), so the bulk op can never leak a variant the single write would not. PARTIAL SUCCESS with per-item results: one bad item (foreign id, or a public-scope publish refused by the Enterprise policy gate) fails that item alone and the rest still apply — the response reports each item's outcome. Idempotent: re-sending the same operation leaves the same state. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects (BulkAudiencePublishOperation) [ 1 .. 100 ] items

Responses

Request samples

Content type
application/json
{
  • "operations": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

The audit trail of audience publish/unpublish/variant changes

PA-232 (ATH-315) — Doc 07 §5 "audit trail of audience changes". Reads the append-only audit_logs (ATH-013) filtered to this workspace's audience.* actions, newest first. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
limit
integer [ 1 .. 100 ]
Default: 25
cursor
string

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

The public-publish clearance for one content item

PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Reports whether this content has the two things a public-scope publish requires under the Enterprise feature.audiences.public_policy: a recorded redaction scan pass AND a content_manager approval. data is null when no clearance has been recorded. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "guide" "article"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Record (or withdraw) the public-publish clearance

PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Idempotent by content. approved:true records the acting content_manager (the route's helpcenter.manage gate IS the content_manager-tier check) as the approver; approved:false withdraws it. redaction_scan_passed records the scan signal — set here manually until PA-233 automates the leak scan that will own this field. Clearance requires BOTH. Requires helpcenter.manage + the flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

contentType
required
string
Enum: "guide" "article"
contentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
approved
boolean

true records the acting content_manager as approver; false withdraws.

redaction_scan_passed
boolean

Records (or clears) the redaction-scan-pass signal; PA-233 will own this.

Responses

Request samples

Content type
application/json
{
  • "approved": true,
  • "redaction_scan_passed": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Inbox

Conversations, messages, routing, and the email channel (Doc 04 §4–6).

Agents' conversation list (filters, previews)

Requires inbox.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
state
string
Enum: "pending" "open" "snoozed" "resolved"
assignee_id
string
inbox_id
string
kind
string
Enum: "conversation" "ticket"

ATH-214 — narrow the same list to tickets (or to non-ticket conversations)

quarantined
boolean

ATH-221 — the spam folder. Omitted or false, quarantined conversations are EXCLUDED, which is the whole benefit of quarantine: this list is capped at 100 rows, so leaving spam in it would push real customers off the end. Pass true to list only the quarantined ones and release false positives from there.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Full thread INCLUDING private notes (agents only)

Requires inbox.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Reply (or private note) via the shared message path

ATH-131 — note=true writes a private note: it NEVER publishes on the conversation channel (the visitor holds it) — notes ride the agent-only workspace inbox channel.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
body
required
string <= 10000 characters
note
boolean
Default: false

Responses

Request samples

Content type
application/json
{
  • "body": "string",
  • "note": false
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Context sidebar — profile, live session, history

Requires inbox.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Agent-assist copilot — ask, draft a reply, or rewrite tone

ATH-220 (Doc 05 §7). The agent-FACING assistant: it answers a teammate's question about the conversation or the knowledge base, drafts a reply for that teammate to edit and send, or rewrites their draft in a different tone. It never sends anything — the human sends.

Requires inbox.reply and the feature.ai.full_suite plan entitlement; below the flag the surface 404s. Unlike the customer-facing agent this path retrieves internal audience knowledge as well as public and customers, so its answers may quote material a customer must never see. Every call is metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402 rather than a silent failure.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
mode
required
string
Enum: "ask" "draft" "rewrite"

ask = answer a teammate's question; draft = propose a reply to the customer; rewrite = restyle the teammate's own text

question
string or null <= 2000 characters

Required for ask. Optional steer for draft; omitted it drafts against the latest customer message.

text
string or null <= 10000 characters

Required for rewrite — the teammate's draft to restyle.

tone
string
Enum: "formal" "friendly" "shorten"

Required for rewrite. Omitted entirely for the other modes.

Responses

Request samples

Content type
application/json
{
  • "mode": "ask",
  • "question": "string",
  • "text": "string",
  • "tone": "formal"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Generate (or regenerate) the AI summary of a conversation

ATH-221 (Doc 05 §7). Summarises the thread for a teammate picking it up cold. Idempotent per conversation — there is one summary row, and a repeat call REGENERATES it in place against the transcript as it now stands. A summary generated before later messages arrived is marked stale so nobody acts on a stale picture without knowing it is one.

Requires inbox.reply and the feature.ai.full_suite plan entitlement plus the feature.ai.summaries rollout flag; below either the surface 404s. Metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Turn LiveTranslate on or off for one conversation

ATH-223 (Doc 04 §9). The per-conversation auto-translate toggle. Switching it ON translates subsequent messages in BOTH directions — inbound into agent_locale for the team, outbound into the customer's detected language for them — and backfills a bounded window of the existing thread so the agent can read what was said before they arrived.

Translation never blocks delivery. Messages are written and broadcast first and translated afterwards on a queued job, so a gateway outage or an exhausted balance degrades the conversation to untranslated rather than breaking it. Nothing on this surface returns 402 for that reason: the trigger for a translation is a customer sending a message, and a customer is not the person who can top up a workspace's credits.

Switching it OFF stops future translations and deletes nothing — existing translations stay readable, because a thread an agent has been reading in English should not become unreadable mid-conversation.

Requires inbox.reply and the feature.ai.full_suite plan entitlement plus the feature.ai.live_translate rollout flag; below either the surface 404s.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
enabled
required
boolean
agent_locale
string <= 24 characters

The language the team reads this thread in. Required when enabling; a BCP-47 tag whose language subtag is on ATH-242's closed ISO 639-1 allowlist.

Responses

Request samples

Content type
application/json
{
  • "enabled": true,
  • "agent_locale": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ask the visitor to share their screen (co-browse)

ATH-261 (Doc 04 §9). An agent requests a live co-browse session on a widget conversation. This does NOT start streaming: it creates a pending session and prompts the visitor, who must EXPLICITLY accept before a single frame is relayed (consent is mandatory, and the server refuses frames until it is given). The session auto-expires after a bounded window, and either party can end it.

The visitor's screen is serialised with rrweb and relayed as EPHEMERAL realtime events on the conversation channel — never stored. Password fields and anything the site marks [data-private] are masked in the visitor's browser before serialisation, so the raw values never leave the page (CLAUDE.md rule 3 in a live medium). This session row is the consent + lifecycle record only.

Only widget conversations carry a visitor to watch; other channels 422. Requires inbox.reply and the feature.messaging.cobrowse flag (404 below it).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

End a co-browse session (agent side)

ATH-261 — the agent stops watching. Idempotent: ending an already-ended session returns its terminal state rather than erroring. Publishes cobrowse.ended so the visitor's browser stops capturing at once. Requires inbox.reply.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ask the visitor to turn this co-browse session into a guide

PA-261 (Doc 07 §3 / cobrowse→guide, Option B 2 of 3). On an ACTIVE, consented co-browse session, the agent proposes recording it into an editable guide. This does NOT start recording: it creates a pending guide-capture request and prompts the visitor, who must give a DISTINCT, explicit consent — separate from the co-browse view consent — on the widget before a single step is ingested. Any prior live guide-capture request on this session is superseded (revoked). Requires inbox.reply and the feature.cobrowse.guide_capture flag (404 below it). 422 if the co-browse session is not active.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ask the visitor to join an audio or video call

ATH-262 (Doc 04 §9) — an agent asks the visitor onto a LiveKit call. This does NOT open anyone's microphone: it creates a pending session and prompts the visitor, who must EXPLICITLY accept. No media token is minted for EITHER participant until they do, so an agent cannot reach a visitor's mic or camera without their consent. The visitor may narrow what they accept (a video request accepted as audio) but never widen it.

Media itself flows peer-to-SFU through LiveKit and never touches Cairn's realtime fabric; only the invite/accept/end signalling rides the existing conversation and visitor channels. Nothing is recorded — see CallToken for the grant that says so.

Only widget conversations carry a visitor to call; other channels 422. Requires inbox.reply and the feature.messaging.av_calls flag (404 below it).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
media
string
Default: "audio"
Enum: "audio" "video"

What the agent is asking for. audio grants only the microphone; video grants camera as well. Defaults to audio — the smaller ask.

Responses

Request samples

Content type
application/json
{
  • "media": "audio"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Mint the agent's short-lived LiveKit join token

ATH-262 — the agent's join credential. Refused with 409 unless the session is joinable: active, carrying a non-null consented_at, unexpired and unended. A pre-consent token request gets nothing, which is the guarantee — the agent cannot enter the room before the visitor has said yes.

The token is a short-lived JWT signed with the LiveKit API secret (which never leaves the server), scoped to exactly one room and one identity, and it never outlives the call session's own window. Re-request it on reconnect: every issue re-checks consent and expiry, so our server stays the authority rather than a long-lived bearer. Requires inbox.reply.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Hang up (agent side)

ATH-262 — the agent hangs up. Idempotent: ending an already-ended call returns its terminal state rather than erroring. Publishes call.ended so the visitor's browser tears its LiveKit room down, and asks LiveKit to delete the room so no participant lingers in it. Requires inbox.reply; deliberately NOT flag-gated, so a live call can always be stopped.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Set the workspace's LiveTranslate glossary

ATH-223 (Doc 04 §9, "glossary support"). A flat term → rendering map applied to every LiveTranslate call for this workspace. Workspace-wide rather than per conversation because a glossary answers "what do we call our own product in German", and that answer does not change between two threads — per-conversation glossaries would produce the exact inconsistency a glossary exists to prevent.

A full replacement, not a merge: sending {} clears it. Terms are interpolated into the model prompt, so they are length-capped and count-capped.

Requires ai.configure and the feature.ai.full_suite plan entitlement plus the feature.ai.live_translate rollout flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
required
object <= 200 properties

term → preferred rendering in the target language.

Responses

Request samples

Content type
application/json
{
  • "glossary": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Classify the conversation against the workspace tag vocabulary

ATH-221 (Doc 05 §7 "classifier call on resolve"). This endpoint APPLIES tags, it does not merely propose them — Doc 05 §7 says auto-tagging "powers analytics + mining", and a suggestion queue would mean analytics saw only the subset a human got round to approving.

Because it applies them, every tag carries PROVENANCE: source records whether a model, a human, or a workflow put it there, and the assignment log is append-only, so "the AI tagged this conversation refund and a human removed it" is reconstructible after the fact. The model may only choose from tags the workspace already uses — it never invents vocabulary. Removing a wrong tag is POST .../tags with remove.

Requires inbox.reply, feature.ai.full_suite and the feature.ai.auto_tag rollout flag. Metered; 402 when exhausted.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Add or remove conversation tags as a human

ATH-221 — the human half of the tag surface, and the correction path for anything auto-tagging got wrong. Writes carry source: human and the acting user, so the assignment log distinguishes what a model did from what a person did.

No AI call, no credits. Requires inbox.reply; needs neither AI flag, because tagging by hand is not an AI feature and must keep working when the utilities are switched off.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
add
Array of strings <= 20 items [ items <= 50 characters ]
remove
Array of strings <= 20 items [ items <= 50 characters ]

Responses

Request samples

Content type
application/json
{
  • "add": [
    ],
  • "remove": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Quarantine a conversation as spam, or release it back to the inbox

ATH-221 — the reversal path for the spam filter, and the manual quarantine control (Doc 05 §7).

The asymmetry that shapes this surface: a false positive hides a real customer, so spam is QUARANTINED and never deleted. The conversation, its messages and its contact all survive untouched; only quarantined_at changes, and the thread stays readable through every existing endpoint. spam: false releases it — the row is restored to the inbox, the correction is recorded against the original verdict, and the conversation is never re-classified afterwards, so releasing something is final rather than a thing the model can undo on the next message.

No AI call, no credits — this is a human overriding a classifier. Requires inbox.reply and no AI flag: a workspace that switches the spam filter off must still be able to release whatever it quarantined while it was on.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
spam
required
boolean

true quarantines the conversation; false releases it back to the inbox.

reason
string or null <= 500 characters

Optional note recorded on the verdict — why a human disagreed.

Responses

Request samples

Content type
application/json
{
  • "spam": true,
  • "reason": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Transcribe a teammate's spoken reply into composer text

ATH-222 — voice-to-text (Doc 05 §7). A teammate holds the mic button in the inbox composer, speaks a reply, and gets text back to edit and send. The transcript is RETURNED, never sent: it lands in the composer through ATH-220's insertion seam and a human presses send, exactly as with a copilot draft.

AUDIO IS NOT RETAINED. The recording is staged privately, transcribed, and deleted before this endpoint responds — on the failure path too. No media row is written and there is nothing to fetch afterwards, by design: a dictated reply routinely speaks a customer's details aloud, and a voice is personal data in its own right. The transcript is the artefact; the audio is a courier (CLAUDE.md rule 3, transposed from typed input to spoken).

Metered by DURATION, not tokens (Doc 05 §9, "Voice-to-text (per min) 1¢"), charged in whole minutes rounded up, against the longer of the client's declared duration and the duration the provider actually transcribed. Requires inbox.reply, the feature.ai.full_suite plan entitlement and the feature.ai.voice_to_text rollout flag; below either the surface 404s. An exhausted workspace gets 402.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: multipart/form-data
required
file
required
string <binary>

The recording — webm/ogg/mp3/m4a/mp4/wav/flac, max 10 MB. Anything else is 422.

duration_ms
required
integer [ 1 .. 120000 ]

Recording length as the browser measured it, used to gate credits before the call. Capped at two minutes; longer is 422. It is a floor on the price, not the price — the debit re-derives duration from the transcript.

language
string or null <= 8 characters

BCP-47 hint for the recogniser. Omitted, the provider detects it.

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Transition state (publishes conversation.state_changed)

Requires inbox.reply. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
state
required
string
Enum: "pending" "open" "snoozed" "resolved"

Responses

Request samples

Content type
application/json
{
  • "state": "pending"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Agent typing signal to the visitor (ephemeral)

Requires inbox.reply. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Assign — explicit agent, unassign (null), or round-robin

ATH-132 — round_robin=true picks the eligible agent (a role holding inbox.reply) with the fewest active conversations, tie-broken deterministically. Publishes conversation.assigned.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
assignee_id
string or null
round_robin
boolean
Default: false

Responses

Request samples

Content type
application/json
{
  • "assignee_id": "string",
  • "round_robin": false
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ban the widget visitor behind a conversation (inbox.configure)

ATH-013 — shipped undocumented; found by the spec↔route parity test. Requires inbox.configure. Bans the VISITOR, not the conversation: the thread stays, the person can't start new ones. 422 when the conversation has no widget visitor (an email thread has nobody to ban).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Canned replies for the composer's !bang expansion (inbox.view)

Requires inbox.view — every agent needs them. Scoped to the workspace in the path. q prefix-matches the bang.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
q
string <= 32 characters

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a canned reply (inbox.configure)

Requires inbox.configure — reading shortcuts is every agent's business, authoring them is configuration.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
bang
required
string <= 32 characters ^[a-z0-9_-]+$
title
required
string <= 255 characters
body
required
string <= 5000 characters

Responses

Request samples

Content type
application/json
{
  • "bang": "string",
  • "title": "string",
  • "body": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a canned reply (inbox.configure)

Requires inbox.configure. Scoped to the workspace in the path.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

shortcutId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

The caller's own follow-up reminders (inbox.view)

Requires inbox.view. Reminders are PERSONAL: every route here operates on the calling user's reminders only, never the workspace's. Due processing is cairn:process-reminders.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Remind me about a conversation later (inbox.reply)

Requires inbox.reply. The reminder belongs to the CALLER.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
conversation_id
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

remind_at
required
string <date-time>

Must be in the future.

note
string or null <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "remind_at": "2019-08-24T14:15:22Z",
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Cancel one of your reminders (inbox.reply)

Requires inbox.reply. Only the caller's own reminders are reachable.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

reminderId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Inbound address + custom-domain DNS wizard (ATH-136)

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Provider inbound-email webhook (ATH-136)

Postmark-shape payload; authenticated by the shared provider secret in X-Cairn-Email-Token (constant-time compare). The token path segment routes to the workspace. Not a client API.

Authorizations:
bearerAuth
path Parameters
token
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

Provider delivery/bounce callback (ATH-136)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

Telegram Bot API webhook (ATH-182)

Telegram Update objects (message / edited_message). The workspace id in the path routes the update — the founder registers this exact URL via setWebhook, one bot per workspace — and the per-workspace secret in X-Telegram-Bot-Api-Secret-Token authenticates Telegram (constant-time compare against the vault credential). 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad secret. Telegram sends no delivery receipts, so there is no status sibling. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

WhatsApp Cloud API webhook verification (ATH-180)

Meta's GET verification handshake. Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the workspace's vault verify_token (constant-time compare). The workspace id in the path routes the check — the founder registers this exact URL as the app's callback, one phone number per workspace. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 403 on a bad or missing verify token. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
query Parameters
hub.mode
string
hub.verify_token
string
hub.challenge
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

WhatsApp Cloud API events webhook (ATH-180)

Meta's entry[].changes[].value envelope carrying messages[] (inbound text/media) and/or statuses[] (sent/delivered/read/failed delivery receipts). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message wamid. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Hub-Signature-256
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

Meta Messenger webhook verification (ATH-181)

Meta's GET verification handshake (shared by every Messenger Platform product). Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the workspace's vault verify_token (constant-time compare). The workspace id in the path routes the check — the founder registers this exact URL as the Meta app's Messenger callback, one Facebook Page per workspace. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 403 on a bad or missing verify token. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
query Parameters
hub.mode
string
hub.verify_token
string
hub.challenge
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Meta Messenger events webhook (ATH-181)

Meta's entry[].messaging[] envelope (object=page) carrying message events (text/attachments), delivery receipts (delivery.mids), and read watermarks (read.watermark). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message mid. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Hub-Signature-256
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

Meta Instagram DM webhook verification (ATH-181)

Meta's GET verification handshake for the Instagram messaging product — identical to the Messenger handshake but keyed on the workspace's Instagram vault credential. Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the vault verify_token (constant-time compare). The founder registers this exact URL as the Meta app's Instagram callback, one Instagram professional account per workspace. 404 when the channel is not activated; 403 on a bad or missing verify token. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
query Parameters
hub.mode
string
hub.verify_token
string
hub.challenge
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Meta Instagram DM events webhook (ATH-181)

Meta's entry[].messaging[] envelope (object=instagram) carrying message events (text/attachments) and read receipts (read.mid — the Instagram read event names the specific message, unlike Messenger's watermark). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message mid. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Hub-Signature-256
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

Twilio Programmable Messaging inbound webhook (ATH-183)

Twilio's inbound SMS/MMS webhook. UNLIKE the other channels this body is application/x-www-form-urlencoded, not JSON — Twilio posts From, To, Body, MessageSid, and NumMedia / MediaUrl{N} + MediaContentType{N} for MMS. The sender's From phone (E.164) is the contact key and the thread; MessageSid is the channel_message_id (redelivery dedupe). The workspace id in the path routes the message — the founder registers this exact URL on the Twilio number's Messaging configuration, one number per workspace — and the payload is authenticated by X-Twilio-Signature (HMAC-SHA1 over the full URL plus the alphabetically-sorted POST params, base64, keyed by the workspace auth token; constant-time compare). 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Twilio-Signature
required
string
Request Body schema: application/x-www-form-urlencoded
required
MessageSid
string
From
string
To
string
Body
string
NumMedia
string
MediaUrl0
string
MediaContentType0
string
property name*
additional property
any

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Twilio Programmable Messaging status callback (ATH-183)

Twilio's delivery status callback for an outbound message, also application/x-www-form-urlencoded: MessageSid + MessageStatus (queued/sent/delivered/undelivered/failed). The workspace id in the path routes the callback — the founder sets this exact URL as the StatusCallback on the Twilio number / Messaging Service — and it is authenticated by X-Twilio-Signature exactly as the inbound webhook. delivered/read move the message forward; undelivered/failed record a delivery failure; the weaker queued/sending/sent are ignored (the optimistic delivered_at the send already stamped stands). 404 when the channel is not activated; 401 on a bad or missing signature. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Twilio-Signature
required
string
Request Body schema: application/x-www-form-urlencoded
required
MessageSid
string
MessageStatus
string
ErrorCode
string
property name*
additional property
any

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Twilio Programmable Voice answer webhook (ATH-184)

Twilio's inbound CALL webhook — Programmable Voice, not Messaging, so it is a separate callback URL from /hooks/twilio/inbound. The body is application/x-www-form-urlencoded (CallSid, From, To, CallStatus, Direction) and the RESPONSE IS TwiML (text/xml), not a 204: Twilio plays whatever this returns to the caller. The call becomes a conversation on the phone channel, keyed on the caller's From phone (thread key voice:<E.164>, deliberately distinct from SMS's sms:<E.164> so a call and a text from the same number never share a thread), with CallSid as the channel_message_id — Twilio redelivers the same CallSid until it gets a 2xx, so redelivery is a no-op.

RECORDING IS OFF UNLESS THE WORKSPACE OPTED IN — the feature.messaging.call_recording flag AND the settings.voice.recording_enabled workspace setting must BOTH be true. When they are, the returned TwiML always speaks a recording announcement to the caller before the <Record> verb; when they are not, the TwiML answers and hangs up and no audio is ever requested.

Authenticated by X-Twilio-Signature exactly as the Messaging hooks. Unlike them this route answers 200 with a refusal TwiML — never 404 — for an unknown, suspended, unactivated or un-entitled workspace: a 404 makes Twilio play its own "application error" to a live human, and one identical spoken refusal for every such case reveals nothing a 404 would not. 401 on a bad or missing signature (the caller is not Twilio, so there is nobody to speak to). Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Twilio-Signature
required
string
Request Body schema: application/x-www-form-urlencoded
required
CallSid
string
From
string
To
string
CallStatus
string
Direction
string
property name*
additional property
any

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Twilio Programmable Voice recording-status callback (ATH-184)

Twilio's recordingStatusCallback, fired when a call recording is available: form-encoded CallSid, RecordingSid, RecordingUrl, RecordingDuration, RecordingStatus. Only ever reached when the workspace opted into recording, because only then does the answer webhook above emit a <Record> carrying this URL — and the consent gate is re-checked HERE anyway, so a callback that arrives after the opt-in was withdrawn stores and transcribes nothing.

A completed callback queues the ingest job, which downloads the audio into workspace-scoped media, transcribes it through the ATH-222 voice-to-text seam, and writes the transcript as a message on the call's conversation. RecordingSid is that message's channel_message_id, so a redelivered callback is a no-op rather than a second transcript.

Authenticated by X-Twilio-Signature exactly as the other Twilio hooks. 404 when the channel is not activated and 401 on a bad or missing signature — the SAME posture as the Messaging hooks, and correct here for the reason it is wrong on the answer webhook above: this peer is a machine throughout, so there is no caller on a line to speak a refusal to. 204 for everything it accepts AND for an activated workspace that has not opted into recording or sent nothing to ingest, both of which are real acceptances rather than errors Twilio could act on. Not a client API.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string
header Parameters
X-Twilio-Signature
required
string
Request Body schema: application/x-www-form-urlencoded
required
CallSid
string
RecordingSid
string
RecordingUrl
string
RecordingDuration
string
RecordingStatus
string
property name*
additional property
any

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Sub-inboxes with routing rules

Requires inbox.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a sub-inbox

Requires inbox.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
string <= 255 characters
Array of objects <= 5 items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "routing_rules": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a sub-inbox / its routing rules

Requires inbox.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

inboxId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 255 characters
Array of objects <= 5 items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "routing_rules": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a sub-inbox (conversations fall back to none)

Requires inbox.configure. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

inboxId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

List conversations (ability: conversations:read)

ATH-250 — cursor-paginated over ULID id descending, same contract as /contacts. Read-only in v1: REPLYING through the public API sends a message to a real customer under the workspace's name, and that deserves its own ticket with the channel-window rules (WhatsApp's 24h, ATH-180) and the suppression list wired in, rather than being smuggled in behind a parity checkbox here.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
state
string
Enum: "pending" "open" "snoozed" "resolved"
channel
string
contact_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: contact_id=01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Read one conversation with its thread (ability: conversations:read)

ATH-250 — the conversation plus its messages, oldest first. Internal notes (direction=note) are EXCLUDED: they are staff commentary written in the belief that the customer will never see them, and an integration token is not staff. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
message_limit
integer [ 1 .. 200 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The human queue of tool calls waiting on approval

ATH-204 (Doc 05 §5, acceptance scenario 4) — requires inbox.view and the feature.ai.tool_approvals flag. An approval task is created when a permission rule says a call is allowed but needs a human, and the tool has NOT run when the task appears here. Tasks carry the exact arguments the call was made with, frozen at request time. Scoped to the workspace in the path — another tenant's queue is never visible.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
status
string
Enum: "pending" "approved" "denied" "expired"
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

One approval task including the arguments the call would run with

ATH-204 — requires inbox.view and the feature.ai.tool_approvals flag. Shows the frozen arguments, the rule that demanded approval, who asked, when it expires, and — once settled — who decided and what the execution returned.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

approvalId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Approve a pending tool call and queue it for execution

ATH-204 — requires inbox.reply and the feature.ai.tool_approvals flag. The tool runs asynchronously with the arguments FROZEN at request time; nothing in this request body can change what is executed. Approval is a single-shot state transition claimed with a conditional update, so a double-approve — two operators clicking at once, or a retried request — executes the tool exactly once and returns 409 approval_already_decided for the loser. An expired task cannot be approved.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

approvalId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
note
string or null <= 1000 characters

Optional free-text rationale recorded with the decision.

Responses

Request samples

Content type
application/json
{
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Deny a pending tool call so it never runs

ATH-204 — requires inbox.reply and the feature.ai.tool_approvals flag. The tool is never executed. Like approval this is a single-shot transition, so denying an already-decided task returns 409 approval_already_decided rather than overwriting the record. A workflow run parked on this task is woken and continues with a denied outcome.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

approvalId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
note
string or null <= 1000 characters

Optional free-text rationale recorded with the decision.

Responses

Request samples

Content type
application/json
{
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Integrations

Third-party integrations (Doc 06 §6) — the first is Slack (ATH-254), bi-directional per the catalog table: new customer messages are announced in a channel, and a staff reply in that Slack thread reaches the visitor.

NOT a channel. The omnichannel adapters under Inbox model a place CUSTOMERS talk to a workspace; Slack here is a place the workspace's own STAFF talk about a conversation that arrived somewhere else. That is why a Slack reply is written as an ordinary outbound message authored by a mapped Cairn user rather than as an inbound one, and why an unmapped Slack user cannot post at all.

Slack Events API receiver (ATH-254)

The ONE callback URL for the Cairn Slack app, shared by every workspace — unlike the omnichannel receivers above, there is no workspace id in the path. Slack addresses one app, not one tenant, so the team_id in the body selects the installation and the workspace is derived from it. A team with no live installation is accepted and dropped (204), never 404: the response must not tell an unauthenticated caller which Slack teams are connected to Cairn.

AUTHENTICATION is Slack's request signature and nothing else ('hooks/*' authz exemption, no session, no key). v0= HMAC-SHA256 over the literal bytes v0:{X-Slack-Request-Timestamp}:{raw body} against the app's signing secret, compared with hash_equals. The timestamp is INSIDE the MAC, so a captured request cannot be re-stamped and still verify; it is additionally rejected outside a ±300s window. 300s is both Slack's own published recommendation and ATH-251's WebhookSignature::TOLERANCE_SECONDS, so the platform has ONE replay window rather than two.

The signing secret is APP-level (config), not per-workspace: it belongs to the Cairn Slack app we publish. Per-workspace bot tokens are a different secret and live in the encrypted vault.

Verification happens before the body is interpreted, so url_verification challenges are echoed only when signed. Real events are acknowledged immediately and processed on a queue — Slack retries anything it cannot get a response to within 3s, and a duplicate reply into a customer conversation is worse than a slow one. Not a client API.

Authorizations:
bearerAuth
header Parameters
X-Slack-Signature
required
string
X-Slack-Request-Timestamp
required
string
Request Body schema: application/json
required
type
string
team_id
string
challenge
string
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "type": "string",
  • "team_id": "string",
  • "challenge": "string"
}

Response samples

Content type
application/json
{
  • "challenge": "string"
}

E-commerce platform webhook receiver (ATH-256)

ATH-256 — the ONE callback URL for the Cairn app on a platform, shared by every install, like ATH-254's Slack receiver and unlike the per-tenant omnichannel hooks: there is no workspace id in the path. Shopify signs on behalf of the app, so X-Shopify-Shop-Domain in the delivery selects the connection and the workspace is derived from it. A shop with no live connection is accepted and dropped (204), never 404 — the response must not tell an unauthenticated caller which stores are Cairn customers.

{platform} selects the adapter. WooCommerce sends no webhooks and has no receiver, so /hooks/ecommerce/woocommerce is 404.

AUTHENTICATION is the platform's request signature and nothing else ('hooks/*' authz exemption, no session, no key). X-Shopify-Hmac-Sha256 is base64(HMAC-SHA256(raw body, app secret)), compared with hash_equals over the VERBATIM body bytes. Unlike Slack, Shopify does NOT put a timestamp inside the MAC, so replay is bounded by DEDUP on X-Shopify-Webhook-Id (a redelivery is a no-op), not by a narrow time window: X-Shopify-Triggered-At is only an OUTER freshness bound (default 7 days, comfortably past Shopify's ~48h retry schedule) past which the dedup ledger can no longer be relied on. 401 on a bad, missing or stale signature.

WHAT IT DOES is lifecycle only. app/uninstalled and the mandatory shop/redact destroy the vault credential and revoke the row; customers/redact and customers/data_request are acknowledged and do nothing, because this integration stores no order or customer data to erase or export — GDPR compliance here is a genuine no-op. Order webhooks are not subscribed to. Not a client API.

Authorizations:
bearerAuth
path Parameters
platform
required
string
Value: "shopify"

The e-commerce platform this receiver serves. Only shopify sends webhooks.

header Parameters
X-Shopify-Hmac-Sha256
required
string
X-Shopify-Shop-Domain
required
string
X-Shopify-Topic
required
string
X-Shopify-Webhook-Id
required
string
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "error": {
    }
}

The workspace's Slack connection, if any

ATH-254 — requires integrations.manage. Returns data: null when Slack is not connected (a 200 with an empty answer, not a 404: "no connection" is the normal state of a settings screen, not a missing resource).

The bot token is NEVER included and there is no reveal endpoint. It is written to the encrypted vault by the OAuth callback and read only by the delivery job.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Choose the channel new conversations are announced in

ATH-254 — requires integrations.manage. Setting notify_channel_id is what actually switches notifications on; a connected workspace with no channel chosen is connected but silent, which is the safe default for an integration that forwards customer messages into a chat room.

Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — and for the same reason: a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
notify_channel_id
string or null <= 32 characters

Send null to go silent without disconnecting.

notify_channel_name
string or null <= 255 characters
status
string
Value: "connected"

The only accepted transition — re-enabling after an automatic disable. A workspace cannot disable itself this way; that is what clearing the channel is for.

Responses

Request samples

Content type
application/json
{
  • "notify_channel_id": "string",
  • "notify_channel_name": "string",
  • "status": "connected"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Disconnect Slack

ATH-254 — requires integrations.manage. Three things happen and the order matters: the bot token is handed to Slack's auth.revoke so it stops working at THEIR end, it is deleted from our vault, and the installation row is stamped revoked_at.

The row is stamped rather than deleted (ATH-252's RevokeOAuthInstall discipline) so "when did we stop talking to Slack" survives the disconnect, and re-connecting later reuses it. A revoke call Slack refuses does NOT abort the disconnect — the local token is destroyed regardless, because a disconnect that fails halfway must not leave a usable token behind. Idempotent: disconnecting an already-disconnected workspace keeps the original timestamp.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Mint the Slack authorize URL that begins an install

ATH-254 — requires integrations.manage. Returns the slack.com/oauth/v2/authorize URL the admin's browser is sent to. Nothing is written; the installation row appears only when Slack calls back with a code.

state is an HMAC-signed, short-lived token binding the workspace AND the user who started the flow, so the callback can re-derive both without trusting a query parameter. The callback re-checks integrations.manage against live membership, so a state minted by an admin who was demoted mid-flow is refused.

422 when the Slack app is not configured on this deployment — the client credentials are ours, not the workspace's, and a connect button that leads to a Slack error page is worse than one that explains itself.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{}

Outbound notification log (cursor-paginated)

ATH-254 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted post to Slack, carrying the outcome, the attempt count and — the field that matters when debugging — slack_error, the error string Slack returned in a 200 OK body.

Slack signals failure with HTTP 200 and {"ok": false}, so a transport-only log would show this integration as permanently healthy while every message was being rejected. Doc 06 §6 asks for per-integration health and logs; this is that surface.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "pending" "succeeded" "failed"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

CRM adapters this deployment can connect

ATH-255 — requires integrations.manage. Doc 06 §6 names four CRMs; this returns the ones actually implemented, which is two (HubSpot, Pipedrive). Salesforce and Zoho are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works.

supports_refresh tells the UI whether to expect an OAuth redirect or an API-token field, and is the axis the adapter seam was designed against.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

The workspace's CRM connections

ATH-255 — requires integrations.manage. One row per connected provider; a workspace may sync to more than one at a time.

Access and refresh tokens are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the sync job.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Connect a CRM

ATH-255 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; crm_connections has no token column.

Reconnecting an existing provider REUSES the row rather than creating a second one, which is what preserves the external-id mappings in crm_contact_links — and therefore the idempotency. A fresh connection would orphan every mapping and the next sync would duplicate the workspace's entire contact book in the CRM.

direction defaults to push: connecting a CRM must not rewrite a workspace's existing contacts on the strength of a checkbox. conflict_strategy is only ever consulted under bidirectional, and only for a genuine three-way conflict — see the CrmSyncRun conflicts field.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
provider
required
string
Enum: "hubspot" "pipedrive"
access_token
required
string <= 500 characters

Written to the encrypted vault and to nowhere else. Never returned by any endpoint; there is no reveal.

refresh_token
string or null <= 500 characters

OAuth providers only. Without one, an expiring access token has no path back to health and the connection will land in needs_reauth when it lapses.

expires_in
integer or null >= 1

Seconds until the access token expires.

account_label
string or null <= 255 characters
remote_account_id
string or null <= 64 characters
direction
string
Enum: "push" "pull" "bidirectional"
conflict_strategy
string
Enum: "crm_wins" "cairn_wins"
sync_unsubscribed
boolean
object

Responses

Request samples

Content type
application/json
{
  • "provider": "hubspot",
  • "access_token": "string",
  • "refresh_token": "string",
  • "expires_in": 1,
  • "account_label": "string",
  • "remote_account_id": "string",
  • "direction": "push",
  • "conflict_strategy": "crm_wins",
  • "sync_unsubscribed": true,
  • "field_map": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Change sync direction, conflict policy or field mapping

ATH-255 — requires integrations.manage. field_map is the PII allowlist: a workspace narrows what leaves the building, and keys outside the syncable set are dropped rather than accepted.

Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead. A connection in needs_reauth cannot be re-enabled this way: only re-authorising fixes a revoked grant, so pretending otherwise would just re-break it on the next sync.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
direction
string
Enum: "push" "pull" "bidirectional"
conflict_strategy
string
Enum: "crm_wins" "cairn_wins"
sync_unsubscribed
boolean
object
account_label
string or null <= 255 characters
status
string
Value: "connected"

The only accepted transition — re-enabling after an automatic disable. A connection in needs_reauth is refused, because only re-authorising fixes a revoked grant.

Responses

Request samples

Content type
application/json
{
  • "direction": "push",
  • "conflict_strategy": "crm_wins",
  • "sync_unsubscribed": true,
  • "field_map": {
    },
  • "account_label": "string",
  • "status": "connected"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Disconnect a CRM

ATH-255 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can call that customer's CRM, because the token is gone rather than merely unreferenced.

The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), and the contact links are KEPT. Discarding them would mean a workspace that reconnects re-creates every contact it already has in the CRM.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Queue a contact, or the whole workspace, for syncing

ATH-255 — requires integrations.manage. With contact_id, queues one contact; without, queues a backfill of every contact in the workspace.

THIS IS EXPLICIT BECAUSE IT HAS TO BE. There is no contact.created domain event in packages/core/src/Events (only contact.unsubscribed), so there is nothing for a continuous sync to subscribe to — the same gap that left ATH-253's Zapier "New Contact" trigger unbuilt, tracked on PA-254. Emitting it means adding a dispatch to the contacts domain, which CLAUDE.md rule 6 reserves for that squad rather than for an integration ticket. Sync-on-contact-change is therefore NOT shipped; when the event lands, a listener calling the same action is the whole remaining change.

Idempotent in two layers: a contact with a run already pending is not queued twice, and crm_contact_links makes even a genuinely duplicated run converge on the same CRM record rather than creating a second one.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
contact_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Omit to queue a backfill of every contact in the workspace.

Responses

Request samples

Content type
application/json
{
  • "contact_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Contact sync log (cursor-paginated)

ATH-255 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted sync of one contact.

Two fields carry the information a status code cannot. provider_error is the vendor's own code — Pipedrive reports failure as HTTP 200 with {"success": false}, so a transport-only log would show this integration as permanently healthy while nothing synced. conflicts records BOTH values of every field where the two systems genuinely disagreed, so the value that lost the merge is discarded rather than lost.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "pending" "succeeded" "failed"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

The workspace's Segment source

ATH-257 — requires integrations.manage. Streams Cairn's domain events OUT to the workspace's Segment/CDP as track/identify calls, the mirror image of the outbound webhooks pointed at one fixed vendor endpoint. A workspace has exactly ONE source, so this is a singular resource with no id in the path; data is null when none is configured.

The Segment write key is NEVER included and there is no reveal endpoint. It is written to the encrypted vault and read only by the delivery job.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Connect (or re-key) the Segment source

ATH-257 — requires integrations.manage. The write key in the request body is written to the encrypted vault and to nowhere else; segment_source_connections has no key column.

Reconnecting REUSES the row rather than creating a second one — a workspace has one source, and reusing the row keeps its delivery history across a re-key. region picks the Segment Tracking API host (us = api.segment.io, eu = events.eu1.segmentapis.com); a write key is minted for one region, and streaming EU-resident data to the US host is a residency violation, not a routing detail.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
write_key
required
string <= 255 characters

The Segment source write key. Written to the encrypted vault and to nowhere else; never returned by any endpoint, and there is no reveal. Sent to Segment as HTTP Basic auth (base64(write_key + ":")), never in a query string or a log.

region
string
Enum: "us" "eu"

Defaults to us.

label
string or null <= 255 characters

Responses

Request samples

Content type
application/json
{
  • "write_key": "string",
  • "region": "us",
  • "label": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Change region or label, or re-enable after an automatic disable

ATH-257 — requires integrations.manage. Re-enabling after an automatic disable clears the failure counter and recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken source rather than have a retry quietly resume one we have evidence is dead (most often a write key the customer rotated in Segment, which is fixed by re-connecting with a new key rather than by re-enabling).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
region
string
Enum: "us" "eu"
label
string or null <= 255 characters
status
string
Value: "connected"

The only accepted transition — re-enabling after an automatic disable, which clears the failure counter and reason. A source disabled for a rejected write key should be RE-CONNECTED with a new key instead; re-enabling alone would re-break it on the next delivery.

Responses

Request samples

Content type
application/json
{
  • "region": "us",
  • "label": "string",
  • "status": "connected"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Disconnect the Segment source

ATH-257 — requires integrations.manage. The vault write key is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can stream another event to that customer's Segment workspace, because the key is gone rather than merely unreferenced. The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), so a reconnect reuses it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Segment delivery log (cursor-paginated)

ATH-257 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per domain event streamed to Segment, with its own retry lifecycle. message_id is the Segment messageId — stable across retries, so Segment dedupes on it and a retried delivery is not double-counted in the warehouse.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "pending" "succeeded" "failed"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

External knowledge tools this deployment can push to

ATH-245 — requires integrations.manage. Doc 06 §6 names four external tools to push OUT to; this returns the ones actually implemented, which is two (Confluence, Zendesk Guide). Notion and SharePoint are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works, and Notion in particular is a block-tree content model whose faithful converter is a ticket of its own, not a thin follow-up.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

The workspace's content-push connections

ATH-245 — requires integrations.manage. One row per connected target; a workspace may push to more than one at a time.

The account email and API token are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the push job.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Connect an external knowledge tool

ATH-245 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; content_push_connections has no token column.

Reconnecting an existing target REUSES the row rather than creating a second one, which is what preserves the external-id mappings in content_push_links — and therefore the idempotency. A fresh connection would orphan every mapping and the next push would publish a second copy of every article into the customer's wiki.

base_url is the customer's OWN Confluence/Zendesk host. It is validated at connect time against the outbound guard (an internal or single-label host is refused with 422) and re-checked on every push, because a public hostname can resolve to a private address later. auto_push defaults false: replicating published content into a third-party system on every publish is an opt-in choice.

So does auto_retract (PA-269), and for the mirror-image reason: reaching back into a customer's own Confluence or Zendesk to unpublish a page is an action on data we do not own. Off, a retraction in Cairn leaves the remote copy exactly as it is.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
target
required
string
Enum: "confluence" "zendesk"
base_url
required
string <= 500 characters

The customer's Confluence wiki base (https://acme.atlassian.net/wiki) or Zendesk base (https://acme.zendesk.com). Refused at connect time if it names an internal, single-label or credential-bearing host.

email
required
string <= 255 characters

The account email of the API token. Written to the encrypted vault with the token and to nowhere else; never returned.

api_token
required
string <= 500 characters

The Confluence/Zendesk API token. Written to the encrypted vault and to nowhere else. Never returned by any endpoint; there is no reveal.

remote_container
string or null <= 128 characters

The Confluence space key or Zendesk section id to create pages in.

account_label
string or null <= 255 characters
auto_push
boolean
auto_retract
boolean

Responses

Request samples

Content type
application/json
{
  • "target": "confluence",
  • "base_url": "string",
  • "email": "string",
  • "api_token": "string",
  • "remote_container": "string",
  • "account_label": "string",
  • "auto_push": true,
  • "auto_retract": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Change auto-push, auto-retract, label, destination container or target settings

ATH-245 — requires integrations.manage. Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead. A connection in needs_reauth cannot be re-enabled this way: the target rejected the credential, so only reconnecting fixes it.

PA-273 — this is also where a Zendesk connection's access controls are set (target_settings.permission_group_id / user_segment_id). It extends this endpoint rather than adding a second one, following PA-266: these are connection configuration, and configuration of one connection belongs on the one PATCH that configures it.

target_settings is DEEP-MERGED into the stored bag, so sending one key never deletes its siblings (PA-270's regression, which this codebase has already fixed twice). An explicit null clears a key, and clearing means Cairn stops sending the field at all — it does NOT send a null to Zendesk, which would read as "visible to everyone" for user_segment_id. Keys the connection's target does not understand are refused with 422 rather than stored: a Confluence connection accepts no target_settings keys at all today.

The ids are Zendesk's own numeric ids, which a Guide admin reads out of the Zendesk admin UI. Cairn does not list them for you: an id-picker would have to read the vault credential from a web request (today only the push job does) and make a synchronous, paginated call to the customer's Zendesk from inside one (rule 5 says queue anything external). Both are real design changes, so the picker is a separate ticket and this endpoint takes the ids directly.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
auto_push
boolean
auto_retract
boolean
account_label
string or null <= 255 characters
remote_container
string or null <= 128 characters
object (ContentPushTargetSettings)

PA-324 — deliberately has NO required list. PA-318 gave it [permission_group_id], which contradicted this schema's own description twice over: omission is a documented, meaningful state ("omitted or null = not sent"), and both keys are Zendesk-only, so a Confluence connection's bag is legitimately empty. An empty bag is the common case, not a defect.

PA-273 — the settings that only ONE target understands, kept off the shared columns that every target has to share a meaning for.

remote_container is the cautionary precedent: one column whose meaning is "the Confluence space key, OR the Zendesk section id", which every reader has to disambiguate against the row's target. A second and third such column (permission groups and user segments exist in Zendesk and have no Confluence counterpart) would be permanently null for half the table. This bag is where per-target concepts go instead, and it is where ATH-242's per-locale mapping will go when it lands — a locale→translation map is not a scalar column under any design.

The bag is CLOSED (additionalProperties: false) and validated AGAINST THE ROW'S TARGET: a key this connection's target does not understand is a 422, never a stored value that is enforced nowhere. Confluence understands no keys today, so a target_settings PATCH against a Confluence connection is refused rather than quietly kept. PA-271 argued the closed-bag case for AgentSettings; it holds here for the stronger reason that these keys are access controls.

WRITES ARE A DEEP MERGE, NOT A REPLACE (PA-270/PA-271's SettingsMerge): PATCHing permission_group_id alone leaves user_segment_id exactly as it was. Send an explicit null to CLEAR a key — which means "stop sending this field to Zendesk", NOT "send null to Zendesk". The distinction is load-bearing: see user_segment_id.

status
string
Value: "connected"

The only accepted transition — re-enabling after an automatic disable. A connection in needs_reauth is refused, because only reconnecting fixes a rejected credential.

Responses

Request samples

Content type
application/json
{
  • "auto_push": true,
  • "auto_retract": true,
  • "account_label": "string",
  • "remote_container": "string",
  • "target_settings": {
    },
  • "status": "connected"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Disconnect an external knowledge tool

ATH-245 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can call that customer's Confluence or Zendesk, because the token is gone rather than merely unreferenced.

The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), and the push links are KEPT. Discarding them would mean a workspace that reconnects publishes a second copy of every article it already has over there. Content already pushed is NOT removed from the remote — it is a copy under the customer's own governance, and disconnecting stops the writes rather than retracting them.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Push a published article, or the whole help centre

ATH-245 — requires integrations.manage. With article_id, pushes one PUBLISHED article; without, backfills every published article in the workspace. A draft is refused (422): only published content leaves.

Automatic on-publish push exists too and is NOT this endpoint: a listener on the existing content.published event (ATH-151) queues a push for every connection with auto_push on, for PUBLIC articles only. This endpoint is the manual "push now" / backfill, and — unlike the auto path — an operator invoking it may push a non-public published article, because that is an explicit human choice rather than an automation acting on content somebody kept off the open help centre.

Idempotent in two layers: an article with a run already pending is not queued twice, and content_push_links makes even a genuinely duplicated run UPDATE the same remote page rather than publishing a second copy.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
article_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Omit to backfill every published article in the workspace.

Responses

Request samples

Content type
application/json
{
  • "article_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Content push log (cursor-paginated)

ATH-245 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted push of one article.

Two fields carry what a status code cannot. provider_error is the target's own error code — Confluence answers a stale page version with 409, Zendesk wraps field errors in a 422 details object. conversion_notes records what the HTML→target conversion could not carry faithfully (a relative image URL that will not resolve remotely, an element with no target representation), so a lossy push is visible in the log rather than only in a diff nobody runs.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
status
string
Enum: "pending" "succeeded" "failed"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

E-commerce platforms this deployment can connect

ATH-256 — requires integrations.manage. Doc 06 §6 names four platforms; this returns the ones actually implemented, which is two (Shopify, WooCommerce). Adobe Commerce and PrestaShop are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works.

supports_oauth tells the connect screen whether to collect a shop domain and an access token (Shopify) or a site URL and a consumer key/secret (WooCommerce) — the axis the adapter seam was designed against. handles_inbound_webhooks is false for WooCommerce, which sends none (see the schema).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

The workspace's connected stores

ATH-256 — requires integrations.manage. One row per connected store; a workspace may connect several at once, across platforms or even two on one platform.

Access tokens and consumer secrets are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the fetch path.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Connect an e-commerce store

ATH-256 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; ecommerce_connections has no token column.

The body shape depends on platform. Shopify wants store_domain (acme.myshopify.com) and access_token; WooCommerce wants store_url (an https:// site URL) and a consumer_key / consumer_secret pair. A wrong-shaped body is a 422 naming the missing field.

Reconnecting the same store REUSES its row rather than creating a second one, which keeps the webhook-dedup ledger's foreign key intact so an app/uninstalled webhook arriving twice during a reconnect is still processed once.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
platform
required
string
Enum: "shopify" "woocommerce"
store_label
string or null <= 255 characters
store_domain
string <= 255 characters

Shopify only — acme.myshopify.com.

access_token
string <= 500 characters

Shopify only. Written to the vault; never returned.

store_url
string <= 512 characters

WooCommerce only — an https:// site URL. http is refused.

consumer_key
string <= 255 characters

WooCommerce only. Written to the vault; never returned.

consumer_secret
string <= 255 characters

WooCommerce only. Written to the vault; never returned.

Responses

Request samples

Content type
application/json
{
  • "platform": "shopify",
  • "store_label": "string",
  • "store_domain": "string",
  • "access_token": "string",
  • "store_url": "string",
  • "consumer_key": "string",
  • "consumer_secret": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename a store, or pause and resume it

ATH-256 — requires integrations.manage. disabled is an admin pause, never an automatic state: the read is synchronous, so a store outage costs a workspace some grey panels and nothing else, and auto-disabling a good connection over it would need a human to undo. A connection in needs_reauth cannot be resumed this way — only reconnecting with a fresh credential fixes a rejected one, so pretending otherwise would just re-break it on the next read.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
store_label
string or null <= 255 characters
status
string
Enum: "connected" "disabled"

disabled pauses reads without surrendering the credential; connected resumes. A connection in needs_reauth cannot be resumed here — only reconnecting fixes a rejected credential.

Responses

Request samples

Content type
application/json
{
  • "store_label": "string",
  • "status": "connected"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Disconnect a store

ATH-256 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no request, replay or operator can call that merchant's store, because the token is gone rather than merely unreferenced.

The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline). There is nothing else to clean up — this integration stored no order data to purge, which is the fetch-on-demand decision paying out.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

connectionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

A customer's orders across connected stores (inbox sidebar)

ATH-256 — requires inbox.view, one tier below the management surface, because the reader is a support agent mid-conversation and this is a card in ATH-135's context sidebar. Fetched LIVE from each store on demand; nothing is synced or stored (see the EcommerceStoreContext schema on the state field and the data-residency reasoning).

Keyed by email — the join key Cairn and a storefront reliably share — rather than by conversation id, so the integration does not reach into the conversations domain to resolve a contact. Every connected store is asked and every answer is returned, including a store that is throttled or down, so an agent never mistakes "we could not ask this store" for "this customer has no orders here".

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
email
required
string <email> <= 255 characters
connection_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: connection_id=01j8me9fycv0q4t4c7wz8k2xt1

Scope the lookup to a single store instead of all of them.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Widget

The public widget surface (Doc 04 §2). These endpoints carry no session: a widget key or signed visitor token names the tenant.

Open an anonymous visitor session (public website key)

ATH-117 — the embed snippet's ws_pub key is the only credential; it can mint anonymous visitor sessions and nothing else. The bearer token is returned exactly once and lives in the widget's localStorage. Suspended workspaces don't serve.

Authorizations:
bearerAuth
Request Body schema: application/json
required
website_id
required
string <= 40 characters

Responses

Request samples

Content type
application/json
{
  • "website_id": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Email capture — upgrade the visitor to a contact

ATH-117 — find-or-create by (workspace, email), link the visitor, and stamp the contact onto the visitor's existing conversations so history follows the person. ATH-118 — pass hmac = HMAC-SHA256(lowercased email, widget_identity_secret), signed by YOUR backend, to verify the identity (Crisp-style anti-impersonation). An invalid or unconfigured signature is 403; claiming an already-verified identity WITHOUT a signature is 403; verification only ever ratchets up.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
email
required
string <email> <= 255 characters
name
string or null <= 255 characters
hmac
string or null <= 64 characters

HMAC-SHA256(email, widget_identity_secret) hex

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "name": "string",
  • "hmac": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Public widget boot config (brand, availability, theme)

ATH-120 — cached 60s per workspace; online is computed from working hours per request (up to 60s staleness on the cached spec is accepted). white_label is plan-gated and re-checked at config build, so downgrades revoke within the TTL.

Authorizations:
bearerAuth
query Parameters
website_id
required
string <= 40 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Widget presentation settings

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update widget presentation settings

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
position
required
string
Enum: "left" "right"
launcher_color
required
string or null

#rrggbb; null = brand primary

greeting
required
string or null <= 200 characters
working_hours
required
object or null

{timezone, days: {mon: [start, end], …}}; missing day = closed; null = always online

offline_form
required
boolean
struggle_detection_enabled
required
boolean

PA-229 (Doc 07 §4) — the real per-workspace enablement PA-227 deferred: when true (and feature.guidance.nudges is active) the client starts the struggle detectors and the nudge engine. The public widget config resolves (flag AND this setting) into its struggle_detection boolean; this row is the setting half. Default false.

nudge_sensitivity
required
string
Enum: "low" "medium" "high"

PA-229 — the tuning slider. Maps client-side to which struggle kinds may nudge and the frequency-cap cooldowns (low = fewer, quieter nudges; high = more, sooner). Default medium.

nudge_page_blocklist
required
Array of strings <= 200 items [ items <= 2048 characters ]

PA-229 — URL patterns (the same urlMatches rule the page map uses) where nudges are suppressed entirely, even on a struggle signal. Default empty.

Responses

Request samples

Content type
application/json
{
  • "position": "left",
  • "launcher_color": "string",
  • "greeting": "string",
  • "working_hours": { },
  • "offline_form": true,
  • "struggle_detection_enabled": true,
  • "nudge_sensitivity": "low",
  • "nudge_page_blocklist": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Proactive-message trigger rules

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Create a trigger rule

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
string <= 255 characters
enabled
boolean
message
string <= 500 characters
Array of objects [ 1 .. 5 ] items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "enabled": true,
  • "message": "string",
  • "conditions": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a trigger rule

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

triggerId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 255 characters
enabled
boolean
message
string <= 500 characters
Array of objects [ 1 .. 5 ] items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "enabled": true,
  • "message": "string",
  • "conditions": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a trigger rule

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

triggerId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Install-detected ping (loader, once per browser)

ATH-123 — the first ping stamps widget_installed_at; later pings are no-ops. Keyed by the public website id, throttled.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
website_id
required
string <= 40 characters

Responses

Request samples

Content type
application/json
{
  • "website_id": "string"
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Install snippet + detection state (Settings→Widget)

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Help mode — KB search inside the widget (public articles only)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
query Parameters
q
required
string [ 2 .. 200 ] characters

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Published public article for in-widget rendering

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
path Parameters
slug
required
string

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Published guide with steps for the walkthrough player

ATH-270 (Doc 07 §2.1) — the payload the walkthrough runtime replays: steps with structured element data and signed media URLs, same shape as getGuide. Published guides only, pinned to the visitor's workspace. No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Published guides relevant to the visitor's current page

PA-229 (Doc 07 §4) — the visitor-token read the client nudge engine queries on a struggle signal: the PUBLISHED guides relevant to a page URL, ranked pinned-first. It is the widget-facing projection of the PA-228 page↔knowledge map (PageKnowledgeMap), reusing the SAME tolerant urlMatches rule so client and server agree on relevance — the client never re-derives guide relevance. Only guide_id + title are returned (the toast label + the walkthrough target); the matched STEP is resolved client-side from the guide the walkthrough player already fetches, so step indices never diverge. Draft/unpublished guides are omitted (a nudge must never open a guide the walkthrough endpoint would 404). The bearer visitor token names the tenant, so the result is pinned to the visitor's workspace (rule 2). Dark below feature.guidance.nudges — 404, so an off workspace leaks nothing.

Authorizations:
bearerAuth
query Parameters
url
required
string <= 2048 characters

The visitor's current page URL to resolve relevant guides for.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Report one nudge-lifecycle outcome (privacy-light)

PA-229 (Doc 07 §4) — the client reports each step of a nudge's lifecycle here so the tuning dashboard can measure the funnel (shown → helped → conversation avoided). The bearer visitor token names the tenant; a nudge_events row is written under it.

A DEDICATED visitor-token endpoint rather than the beacon collector, for two reasons: it is idempotent on the natural (nudge_id, phase) key — a phase is reported at most once per nudge, so a retry (or the Idempotency-Key replay) is a no-op, which the beacon's fire-and-forget batch cannot guarantee — and the visitor session authenticates the workspace without trusting a client-supplied website key.

PRIVACY (rule 3, PA-227's stance): a nudge event carries INTERACTION METADATA ONLY — the nudge/guide ids, the struggle kind, the page (stored as origin+path, query and hash dropped), and the lifecycle phase. It never carries anything the visitor typed or that any element showed. Dark below feature.guidance.nudges (404).

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
nudge_id
required
string <= 64 characters

Client-generated id grouping one nudge's whole lifecycle.

phase
required
string
Enum: "shown" "dismissed" "clicked" "walkthrough_started" "helped" "conversation_started"

Where in the funnel this report sits. showndismissed/clickedwalkthrough_startedhelped (walkthrough completed); conversation_started when the visitor opened a support conversation after the nudge (the non-deflection outcome).

Ulid (string) or null

The matched guide the nudge pointed at.

kind
string or null <= 32 characters

The struggle kind that triggered the nudge (rage_click, thrash, …).

url
string or null <= 2048 characters

The page the nudge fired on; stored as origin+path only.

Responses

Request samples

Content type
application/json
{
  • "nudge_id": "string",
  • "phase": "shown",
  • "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "kind": "string",
  • "url": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Claim the next in-app campaign message for this visitor

ATH-212 (Doc 04 §6) — an in-app campaign has no push channel, so it is delivered when the audience member is next PRESENT in the widget. This endpoint is that presence signal: the widget calls it once per boot (and again after user.set identifies the visitor), and the server decides — from the campaign's segment audience, which never leaves the server — whether this contact has an undelivered in-app campaign waiting.

When one does, the message is written into the visitor's widget conversation like any other message and returned here so the widget can surface it proactively. null is the ordinary answer and means nothing is waiting: no audience match, an anonymous visitor, a suppressed contact, a frequency cap, or the feature.messaging.in_app_campaigns flag being dark for the workspace. The widget must treat all of those identically.

Delivery is claimed per (campaign, contact) before the message is written, so calling this repeatedly — across reconnects, tabs, devices, or a fresh browser — yields the campaign exactly once. A second call returns null, not the same message again.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor's own conversations

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Open a new conversation (widget channel, pending)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Messages in the visitor's conversation (notes excluded)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Send a visitor message

ATH-116 — writes through the shared SendMessage path: reopens a resolved conversation, publishes message.created (conversation channel) + conversation.updated (workspace inbox) as durable fabric events. Attachments land with the AV pipeline (flagged).

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
body
required
string <= 10000 characters

Responses

Request samples

Content type
application/json
{
  • "body": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ephemeral typing signal (with optional live preview)

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
preview
string or null <= 200 characters

Responses

Request samples

Content type
application/json
{
  • "preview": "string"
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Read receipt — the visitor saw the agent replies

No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

CSAT — rate a resolved conversation 1-5

ATH-134 — only resolved conversations accept ratings; re-rating updates (the customer's last word wins). Agents receive conversation.updated with the score.

Authorizations:
bearerAuth
path Parameters
conversationId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
rating
required
integer [ 1 .. 5 ]

Responses

Request samples

Content type
application/json
{
  • "rating": 1
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor consents to being watched (co-browse)

ATH-261 (Doc 04 §9) — the visitor's EXPLICIT opt-in. Co-browse never starts silently: only after this call does the session go active, and only then will the frames endpoint relay anything. The token in the request scopes the session to this visitor's own conversation; another visitor's session id 404s. A session that is no longer pending (already ended/expired/declined) 409s.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor refuses the co-browse request

ATH-261 — the visitor says no. The session terminates as declined and can never stream; the agent is told via cobrowse.ended. Refusing is always available and always final.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Relay a batch of masked rrweb frames to the watching agent

ATH-261 — the visitor's browser posts a batch of rrweb events; the server relays each as an EPHEMERAL cobrowse.frame on the conversation channel and stores NONE of it. Rejected with 409 unless the session is active (consent given) and not expired — a pre-consent or post-expiry stream carries no frames. Frames are already masked client-side (password + [data-private]); the server never sees raw sensitive values.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects <= 500 items

rrweb event objects, opaque to the server and relayed verbatim.

Responses

Request samples

Content type
application/json
{
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

The visitor stops sharing their screen

ATH-261 — the visitor ends the session at any time. Idempotent; publishes cobrowse.ended. Either party ending is final.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

The visitor consents to recording this co-browse into a guide

PA-261 (Doc 07 §3, Option B) — the visitor's DISTINCT, explicit guide-capture opt-in, separate from the co-browse view consent. Only after this call may the recorder ingest steps. The bearer token scopes the request to this visitor's own co-browse session; another visitor's session id 404s. A request that is no longer pending (already consented, or revoked) 409s.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor withdraws guide-capture consent

PA-261 — the visitor stops the guide recording at any time. Idempotent and always available (never flag-gated), so consent can always be withdrawn: every subsequent start/append is refused at once. The live co-browse VIEW is unaffected — this revokes only the guide recording.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Open the capture session for a consented co-browse guide recording

PA-261 — the recorder opens a CaptureSession (source=cobrowse, no user) for the visitor's masked, structured steps. Fails CLOSED on all three gates: the co-browse session must be active, the agent must have initiated guide capture, and the visitor must have consented and not revoked — missing any is refused (403). Idempotent: the record binds to exactly one capture session, so a repeat returns the same one. 404 below the feature flag.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
object (AppContext)

Responses

Request samples

Content type
application/json
{
  • "app_context": {}
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Batch-append masked capture steps to a consented guide recording

PA-261 — the recorder appends masked, structured steps (labels only, rule 3; selector_candidates required, rule 4), idempotent by (session, seq). Fails CLOSED on the same three gates as start, re-checked on EVERY batch — so revoking consent stops ingest at once. A value-bearing step (raw typed text) is rejected 422 and never stored.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects (CaptureStep) <= 50 items

Responses

Request samples

Content type
application/json
{
  • "steps": [
    ]
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Finalize the co-browse guide recording and assemble the guide

PA-261 — closes the capture session and hands it to the SAME AssembleGuide pipeline any authored capture flows through, yielding an editable draft guide in the agent's authoring flow. Refused (403) if consent was revoked. Idempotent — re-finalizing returns the same guide. A workspace at its plan's guide ceiling gets 402.

Authorizations:
bearerAuth
path Parameters
sessionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor calls the support team (widget call button)

ATH-262 (Doc 04 §9's widget "call" button) — the OTHER direction. The visitor pressing call on their own device, with their own token, IS the consent, so the session is created already active with consented_at set and the visitor may mint a token at once. An agent still needs inbox.reply to mint theirs.

The conversation must be the caller's own widget conversation; any other id 404s. Flag-gated (feature.messaging.av_calls) and throttled.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
conversation_id
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

media
string
Default: "audio"
Enum: "audio" "video"

Responses

Request samples

Content type
application/json
{
  • "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "media": "audio"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor answers the call (the consent gate)

ATH-262 — the visitor's EXPLICIT opt-in, and the ONLY transition that makes a call joinable. Until it happens no token is minted for anybody, so an agent cannot open a visitor's microphone or camera by asking. It can only be driven by the visitor's own token; another visitor's call id 404s.

media may NARROW the request but never widen it: a video request may be answered audio (mic only, and the agent's token is narrowed too), while answering an audio request with video is 422. A call that is no longer pending 409s.

Authorizations:
bearerAuth
path Parameters
callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
media
string
Enum: "audio" "video"

What the visitor is agreeing to. Omit to accept as asked.

Responses

Request samples

Content type
application/json
{
  • "media": "audio"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor refuses the call

ATH-262 — the visitor says no. The call terminates as declined and can never be joined; the agent is told via call.ended. Refusing is always available and always final.

Authorizations:
bearerAuth
path Parameters
callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Mint the visitor's short-lived LiveKit join token

ATH-262 — the visitor's join credential, scoped to their own identity in this call's room and to nothing else. Refused with 409 unless the call is joinable (active, consented, unexpired); the call id is pinned to this visitor's own calls, so another visitor's or another tenant's call 404s. Re-request on reconnect — each issue re-checks that consent still stands.

Authorizations:
bearerAuth
path Parameters
callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The visitor hangs up

ATH-262 — the visitor ends the call at any time. Idempotent; publishes call.ended and asks LiveKit to delete the room. Either party hanging up is final.

Authorizations:
bearerAuth
path Parameters
callId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Register a mobile device for push notifications

ATH-263 (Doc 04 §2.3) — the mobile SDK registers its APNs (ios) or FCM (android) device token so agent/AI replies reach the visitor when the app is backgrounded and no websocket is held. The device is always attached to the AUTHENTICATED visitor — there is no way to register a device for anybody else — and the bearer visitor token names the tenant, exactly like the rest of the widget surface.

The token is a push credential: it is stored encrypted at rest, never returned in a response, and never logged. Re-registering the same token for the same visitor is idempotent (the row is upserted, not duplicated), so the SDK may register on every boot and after every APNs/FCM token rotation. Flag-gated (feature.messaging.mobile_push) and throttled.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
platform
required
string
Enum: "ios" "android"

ios → APNs, android → FCM.

token
required
string <= 4096 characters

The opaque APNs/FCM device token. A push credential — write-only; never echoed back.

Responses

Request samples

Content type
application/json
{
  • "platform": "ios",
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Unregister a mobile device (logout / uninstall)

ATH-263 — drop a device so it stops receiving pushes, e.g. on logout. The id is pinned to the authenticated visitor's own devices, so another visitor's or another tenant's device id 404s rather than 403s — the widget principal cannot even learn it exists. Idempotent from the caller's view. Never gated by the feature flag: a device already holding pushes must always be able to switch them off.

Authorizations:
bearerAuth
path Parameters
deviceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Open a "do it for me" execution run (the server enforces the safety rails)

PA-218 (Doc 07 §2.2 — "agent performs the steps with confirmation"). The execution runtime asks to run a published guide's steps on the page the visitor is on; THIS endpoint is the security boundary that decides whether it may. Requires the feature.automation.execute flag (404 while dark, rule 7) and a valid visitor token.

The server enforces, IN ORDER: the feature flag; the domain allowlist (PA-219 AutomationDomainGuard — the host derived from url must have a verified allowlist row); the sensitive-page rail (PA-219 SensitivePagePolicy — no execution on payment/password pages). A client-side check is bypassable, so the decision is made HERE and cannot be talked out of.

On refusal the run is still AUDITED: an aborted run is recorded with the matching reason (domain_not_allowed or sensitive_page) and the endpoint returns 422 carrying that reason. On success a running run is opened and its id returned; the runtime then records each executed step against it.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
guide_id
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

url
required
string <= 2048 characters

The visitor's current page URL. Its host is derived for the allowlist check and the whole URL is matched against the sensitive-page rail. Never stored as a run value — only the derived host is recorded.

Responses

Request samples

Content type
application/json
{
  • "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "url": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Close a "do it for me" execution run

PA-218 — move a running run to a terminal state: completed (every step ran), aborted (the user stopped it, or a client-side rail such as selector ambiguity fired), or failed. abort_reason is required when the status is aborted and must be absent otherwise.

Pinned to the authenticated visitor's OWN run — another visitor's or another tenant's run id 404s. Deliberately NOT flag-gated: a running run must always be closable (the Stop button can never go dark), exactly as ATH-263 leaves device unregister ungated. Idempotent — a run already terminal is returned unchanged.

Authorizations:
bearerAuth
path Parameters
runId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
status
required
string
Enum: "completed" "aborted" "failed"
abort_reason
string
Enum: "user_stopped" "selector_ambiguous" "sensitive_page" "domain_not_allowed" "error"

Required when status is aborted, optional when failed, and forbidden when completed.

Responses

Request samples

Content type
application/json
{
  • "status": "completed",
  • "abort_reason": "user_stopped"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Re-authorize a running automation after page navigation

PA-283 — resume the SAME audited "do it for me" run after a full-page navigation. Before the client may present the next consent step, the server re-runs the execution safety rails against the NEW page: the feature.automation.execute flag, the verified-domain allowlist, and the workspace-sensitive-page policy. A client-side check is bypassable, so a persisted cursor alone never authorizes execution on a new page.

The run is pinned to the authenticated visitor's own workspace and initiator_visitor_ref; another visitor's or tenant's run id 404s. The run must still be running, otherwise the endpoint returns 409. On a safety-rail refusal the EXISTING run is closed as aborted with domain_not_allowed or sensitive_page, and 422 returns that same run id. Nothing here carries a typed or pre-filled value: the client stores and sends only the run identity, guide identity, next step index, and current page URL.

Authorizations:
bearerAuth
path Parameters
runId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
required
string <= 2048 characters

The newly loaded page URL. The host is checked against the verified allowlist and the whole URL against the sensitive- page rail. The URL itself is never stored on the run.

Responses

Request samples

Content type
application/json
{
  • "url": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Record one executed step of a "do it for me" run

PA-218 — append one executed step to a running run. Idempotent on (run, step_index) (rule 5): a retry of the same index returns the row already written and changes nothing. Pinned to the authenticated visitor's OWN run — another visitor's or another tenant's run id 404s. A step on a run that is no longer running is a 409.

RULE 3 IS THE POINT OF THIS PAYLOAD. There is deliberately NO field that could carry a typed value. A step names WHICH selector resolved the target (selector_used) and THAT an action happened — never the text entered. A field the runtime must not type into (masked or sensitive) is reported with sensitive_manual: true and outcome: skipped_manual, i.e. "the user did this by hand", never its contents. Value-bearing keys (value, text, input, …) are rejected with 422, not merely dropped.

Authorizations:
bearerAuth
path Parameters
runId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
step_index
required
integer >= 0

Zero-based position of this step in the run's execution order.

guide_step_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

action
required
string
Enum: "click" "fill" "navigate" "select" "submit" "scroll" "keypress"

What the runtime did. fill NEVER carries the value entered.

selector_used
string <= 2048 characters

WHICH selector resolved the target — the "where", never the "what".

outcome
required
string
Enum: "ok" "failed" "skipped_manual" "aborted"
sensitive_manual
boolean
Default: false

True when the field was handed to the user for manual entry (masked/sensitive).

duration_ms
integer >= 0
Default: 0
screenshot_ref
string <= 1024 characters

A storage reference to an optional screenshot — never an inline image.

Responses

Request samples

Content type
application/json
{
  • "step_index": 0,
  • "guide_step_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "action": "click",
  • "selector_used": "string",
  • "outcome": "ok",
  • "sensitive_manual": false,
  • "duration_ms": 0,
  • "screenshot_ref": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Page telemetry for the agents' context sidebar

ATH-135 — current page + honest browser-reported timezone and locale, merged into the visitor context; publishes ephemeral visitor.page_changed. IP geolocation (the map) is founder-gated on a geo provider.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
required
string <= 500 characters
title
string or null <= 255 characters
timezone
string or null <= 64 characters
locale
string or null <= 16 characters
referrer
string or null <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "title": "string",
  • "timezone": "string",
  • "locale": "string",
  • "referrer": "string"
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Widget identity HMAC secret (settings managers only)

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Generate (or replace) the widget identity secret

ATH-118 — rotation invalidates every outstanding signature; the customer's backend must re-sign with the new secret.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Realtime

Reverb channel authorisation and the missed-event resume protocol (Doc 04 §3).

Missed-event fetch by cursor (member principal)

ATH-111 — on reconnect, send the channel and your highest seen cursor; receive every DURABLE event after it in cursor order. Ephemeral events (typing, presence, page telemetry) are never replayed. Authorization is the same ChannelAuthorizer that grants live subscriptions. resync=true means continuity can't be proven (cursor predates retention) — refetch state via REST instead of trusting the replay.

Authorizations:
bearerAuth
query Parameters
channel
required
string <= 200 characters

Wire channel name (private-…/presence-…) or bare stem

after
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "latest_cursor": 0,
  • "complete": true,
  • "resync": true
}

Missed-event fetch by cursor (visitor principal)

ATH-111 — identical contract to /realtime/resume, authenticated by the bearer visitor token; grants limited to the visitor's own channels.

Authorizations:
bearerAuth
query Parameters
channel
required
string <= 200 characters
after
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "latest_cursor": 0,
  • "complete": true,
  • "resync": true
}

Visitor channel grant (Pusher-protocol auth signature)

ATH-110 — the widget's counterpart to /broadcasting/auth (Doc 04 §3). Authenticated by the bearer visitor token. Grants exactly two channel shapes — private-visitor.{own id} and private-conversation.{id} the visitor participates in — and 403s everything else (a visitor can never observe agent inbox or presence channels). The returned signature is what the client passes to Reverb to complete the subscription.

Authorizations:
bearerAuth
Request Body schema: application/json
required
channel_name
required
string <= 200 characters
socket_id
required
string <= 50 characters

Responses

Request samples

Content type
application/json
{
  • "channel_name": "string",
  • "socket_id": "string"
}

Response samples

Content type
application/json
{
  • "auth": "string"
}

Exports

Guide/article export jobs — PDF, HTML, Markdown, DOCX (Doc 03 §6).

Download center — recent exports with status + signed URLs

Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Request an export (async — poll listExports until ready)

ATH-105 — queued render into S3; the requester is emailed when ready. Formats come from the exporter registry. ATH-106 adds pdf (headless Chromium render of the print stylesheet, brand kit applied); ATH-107 adds the zip bundles (html_zip: index.html + images/ as files; markdown_zip: guide.md + images/ with relative links); ATH-108 adds docx (PHPWord — title, numbered steps, embedded screenshots). ATH-244 adds video (server-side render of the step sequence to MP4; large, so it carries an expires_at retention window). video is offered only when feature.content.video_export is enabled for the workspace — requesting it otherwise is a 422, the same as an unknown format.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
subject_id
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

format
required
string
Enum: "markdown" "html" "pdf" "html_zip" "markdown_zip" "docx" "video"

Responses

Request samples

Content type
application/json
{
  • "subject_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "format": "markdown"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Analytics

Support dashboard metrics (analytics.view)

Requires analytics.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Content dashboard metrics (analytics.view)

Requires analytics.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

AI dashboard metrics (analytics.view)

ATH-259, Doc 06 §3.3. Requires analytics.view; 404 below feature.analytics.dashboard (the Growth+ plan entitlement). Every number is derived from an existing source rather than a parallel counter: run outcomes come from agent_runs (ATH-160), credit spend from the credit_ledger debits (ATH-166), and the deflection block from the ATH-285 ROI rollup read through the Metrics port. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Per-article deflection-ROI dashboard (analytics.view)

PA-226 (ATH-304), Doc 07 §3 — the content team's value made measurable: per article, the conversations it deflected, the agent-minutes that saved, and the question-cluster volume it now covers. Requires analytics.view; 404 below feature.mining.roi_dashboard (rule 7). Every number is DERIVED from the ATH-285 deflection-ROI rollup read through the Metrics port (deflection.by_source, deflection.total, deflection.minutes) — this surface recomputes nothing and never scans agent_runs or events. Agent-minutes-saved is avoided conversations × a configurable average handle time (handle_minutes_per_contact, CAIRN_DEFLECTION_HANDLE_MINUTES), surfaced alongside the multiplier so the math is auditable. top_intents ranks the deflected question themes by volume; revenue_signal_available is false and every intent's revenue_linked is false because the platform has no per-conversation revenue signal yet — the ranking is the honest volume proxy for "revenue-touching intents", not a fabricated revenue number. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Proactive-guidance nudge tuning dashboard (analytics.view)

PA-229 (Doc 07 §4) — the tuning dashboard over the nudge funnel: shown → engaged → walkthrough started → helped → conversation avoided. Requires analytics.view; 404 below feature.guidance.nudges (rule 7). Every count is DERIVED from the nudge_events log through the metric registry (the same NudgeEventCountSource a custom dashboard resolves), so this surface owns no parallel counter. The rates are arithmetic on those counts (a ratio does not sum across days, so it is computed here, not registered). conversations_avoided is nudges_shown − conversations_started: a nudge that did not lead the visitor into a support conversation deflected one — the honest proxy while the platform has no per-conversation revenue signal. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

The metric vocabulary a custom dashboard may reference (analytics.view)

ATH-259, Doc 06 §3.4. The whitelist of registered metrics — the picker vocabulary. A dashboard references these ids and nothing else; an id absent from this list is a 422 on write. Clients must not hardcode ids, because this list is what validation measures against. 404 below feature.analytics.custom_dashboards.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Saved custom dashboards (analytics.view)

ATH-259, Doc 06 §3.4. The workspace's saved dashboards, newest first. Returns the STORED shape (name + referenced metric ids); resolving a dashboard's numbers is a separate call, so listing never runs every metric in every dashboard. 404 below feature.analytics.custom_dashboards.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Save a custom dashboard (analytics.view)

ATH-259, Doc 06 §3.4. widgets[].metric must be an id from listDashboardMetrics; an unregistered id is a 422. Nothing user-supplied reaches SQL — a dashboard stores references, never a query. 404 below feature.analytics.custom_dashboards.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string <= 120 characters
required
Array of objects (DashboardWidget) [ 1 .. 24 ] items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "widgets": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One custom dashboard, resolved over a window (analytics.view)

ATH-259, Doc 06 §3.4. Each widget's metric id is resolved back through the registry to its source and evaluated over the window; series widgets additionally carry day buckets. A widget whose metric has since been retired from the registry reports available: false rather than rendering a zero — a removed metric must read as "gone", never as "no activity". Another tenant's dashboard is a 404.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

dashboardId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
from
string <date>
to
string <date>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename or recompose a saved dashboard (analytics.view)

ATH-259, Doc 06 §3.4. Both fields optional; widgets carries the same registry whitelist as the create path, so an update cannot name a metric that resolves to no source.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

dashboardId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string <= 120 characters
Array of objects (DashboardWidget) [ 1 .. 24 ] items

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "widgets": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a saved dashboard (analytics.view)

ATH-259, Doc 06 §3.4. Deletes one dashboard in this workspace. Another tenant's dashboard is a 404.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

dashboardId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Client analytics beacon (ATH-140)

Public, consent-gated. The widget/help-center/guide pages batch events here (keyed by the public widget key). Without consent:true nothing is recorded; event names are allowlisted. Not a session API.

Authorizations:
bearerAuth
Request Body schema: application/json
required
website_id
required
string <= 40 characters
consent
boolean
Default: false
visitor
string <= 64 characters

anonymous client id

required
Array of objects <= 50 items

Responses

Request samples

Content type
application/json
{
  • "website_id": "string",
  • "consent": false,
  • "visitor": "string",
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Retrieval

The server-to-server knowledge surface (Doc 06 §7): hybrid retrieval over the workspace corpus plus the document reads behind a hit. Authenticated by a workspace API key — the key IS the principal and names the tenant, so there is no workspace in these paths. Consumed by the Cairn MCP server (tools/mcp-server) and "enterprise search API" integrations.

Hybrid retrieval over the workspace knowledge corpus

ATH-258 — vector + FTS retrieval fused by RRF (the ATH-154 engine) for non-MCP consumers and the Cairn MCP server. Requires a workspace API key with the retrieval:query ability; the key names the tenant, so results can never cross workspaces. Audience governance (Doc 07 §5): keys see public + customers chunks; internal chunks require the retrieval:internal ability and are silently excluded otherwise — requesting an audience the key may not see narrows to the permitted set (fail closed), it never errors content into view.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
query
required
string [ 2 .. 500 ] characters
top_k
integer [ 1 .. 25 ]
Default: 8
object

Responses

Request samples

Content type
application/json
{
  • "query": "string",
  • "top_k": 8,
  • "filters": {
    }
}

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Full published guide behind a retrieval hit (API-key principal)

ATH-258 — the document read behind a guide:{id} hit, for MCP cairn_get_guide. Requires a workspace API key with the guides:read ability. Published guides only, and only those whose share-mode audience the key may see (private guides map to internal and need retrieval:internal); anything else is 404 — existence is never confirmed across the audience fence.

Authorizations:
bearerAuth
path Parameters
guideId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Full published article behind a retrieval hit (API-key principal)

ATH-258 — the document read behind an article:{id} hit, for MCP cairn_get_article. Requires a workspace API key with the articles:read ability. Published articles only, and only those whose access-level audience the key may see (internal access needs retrieval:internal); anything else is 404 — existence is never confirmed across the audience fence.

Authorizations:
bearerAuth
path Parameters
articleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Tools

Per-workspace agent tools (Doc 05 §5, ATH-202): the tool registry surface plus the HTTP tool builder. A kind=http tool is a workspace-authored outbound call — base URL, method, templated path/query/headers/body, auth injected from the encrypted vault, and a response mapping that trims the payload before an agent sees it. Every such call passes an SSRF guard that refuses private, loopback, link-local, and cloud-metadata targets after DNS resolution and re-checks each redirect hop. Requires integrations.manage; the builder is gated on feature.ai.tool_builder.

List the workspace's registered agent tools

ATH-202 — requires integrations.manage. Returns every tool the workspace has registered, of every kind. Credential PAYLOADS are never included; a tool reports only whether a vault credential is linked and under which key. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
kind
string
Enum: "builtin" "http" "mcp"
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Register a tool (http kind = the endpoint builder)

ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. A kind=http tool's config is validated server-side before it is stored — endpoint URL against the SSRF guard's DNS-free rules, method against the read/write allow-list, templates against the declared parameter names, and static headers against carrying credentials (those belong in the vault). New tools are always created DISABLED — enabling is a separate PATCH, after the author has exercised the test-invoke endpoint.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters
slug
string

Unique per workspace. Derived from the name when omitted.

kind
required
string
Enum: "builtin" "http" "mcp"
object

For kind=http this must satisfy HttpToolConfig.

credential_key
string or null

Vault key, in THIS workspace, whose payload is injected as auth.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "slug": "string",
  • "kind": "builtin",
  • "config": { },
  • "credential_key": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

A single tool including its full configuration

ATH-202 — requires integrations.manage. Scoped to the workspace in the path — another workspace's toolId is a 404, never a read.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a tool's name, configuration, credential link, or enabled flag

ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. A supplied config is fully re-validated; enabling a tool whose config no longer validates is refused with 422 rather than left to fail at call time. Linking a credential names a vault KEY belonging to this workspace — a key from another tenant is a validation error, not a silent null.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 120 ] characters
object
credential_key
string or null
enabled
boolean

Turning a tool on is what exposes it to the agent runtime. Refused with 422 when the tool's config does not validate.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "config": { },
  • "credential_key": "string",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a tool

ATH-202 — requires integrations.manage. The linked vault credential is NOT deleted: one credential can back several tools and removing a definition must not revoke a secret out from under the others.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Parse an OpenAPI document and list the operations importable as tools

ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. Give either url (fetched through the same SSRF guard as a tool call — a spec URL is the same vector) or document (a pasted JSON or YAML spec). Nothing is persisted; the response carries a ready-to-store config per importable operation plus an explicit refusals list naming every operation that was skipped and why. The importer supports the common shape (OpenAPI 3.0/3.1, path/query/header parameters, JSON request bodies, local $ref) and REFUSES the rest loudly rather than generating a tool that would call the wrong thing.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
string <uri>

Location of the OpenAPI document. Fetched through the SSRF guard, size-capped, redirects re-checked.

document
string

A pasted OpenAPI 3.0/3.1 document, JSON or YAML.

base_url
string

Overrides the spec's servers[0].url. Required when the spec has none, has a relative one, or uses {variables}.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Run a tool once as its author, to check it before enabling it

ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. Runs the tool with the supplied arguments and returns the mapped result envelope, so an author can see exactly what an agent would receive. Works on a DISABLED tool by design: a tool is fail-closed until someone has confirmed it works, so a try-it that honoured the enabled flag could never be used.

ATH-204 NARROWED THIS BYPASS. Only the enabled flag is skipped here. Permission rules are evaluated on this route exactly as they are for an agent, with caller kind human — so a rule that denies the call denies it here too, and a rule that requires approval opens an approval task and returns status=pending_approval WITHOUT running the tool. The earlier contract skipped the whole authorizer, which let an operator run a rule-denied or approval-gated tool straight from this button.

The SSRF guard, timeout, and response cap all still apply. When the call is allowed the request really is made, so a write-verb tool really writes.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
object

The arguments an agent would pass, matching the tool's declared parameter schema.

Responses

Request samples

Content type
application/json
{
  • "arguments": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List the permission rules governing a tool

ATH-204 (Doc 05 §5) — requires integrations.manage and the feature.ai.tool_approvals flag. Rules are returned in EVALUATION order (priority ascending, then id). The first rule whose caller kind and argument constraints match decides the call. If no rule matches the call is DENIED — the engine is fail-closed, mirroring the enabled column's default-false posture, so a tool with no rules cannot be run by anything.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Add a permission rule to a tool

ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. A rule pairs a caller kind (agent, workflow, human, or any) and an optional set of argument constraints with an effect: allow runs the tool, require_approval opens an approval task and runs the tool only after a human approves, deny refuses outright. Constraints are ANDed; a constraint whose argument is missing or of the wrong type does NOT match, so a refund <= 5000 rule can never be satisfied by an absent amount.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
caller_kind
required
string
Enum: "agent" "workflow" "human" "any"
effect
required
string
Enum: "allow" "require_approval" "deny"
priority
integer [ 0 .. 10000 ]
Default: 100
Array of objects (ToolArgumentConstraint)
approval_ttl_minutes
integer or null [ 1 .. 43200 ]
description
string or null <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "caller_kind": "agent",
  • "effect": "allow",
  • "priority": 100,
  • "constraints": [
    ],
  • "approval_ttl_minutes": 1,
  • "description": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a permission rule

ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. Editing a rule changes what FUTURE calls are allowed to do; approval tasks already opened keep the arguments and the effect they were created with, so a pending refund cannot change size because someone edited the rule while it sat in the queue.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ruleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
caller_kind
string
Enum: "agent" "workflow" "human" "any"
effect
string
Enum: "allow" "require_approval" "deny"
priority
integer [ 0 .. 10000 ]
Array of objects (ToolArgumentConstraint)
approval_ttl_minutes
integer or null [ 1 .. 43200 ]
description
string or null <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "caller_kind": "agent",
  • "effect": "allow",
  • "priority": 10000,
  • "constraints": [
    ],
  • "approval_ttl_minutes": 1,
  • "description": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a permission rule

ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. Deleting the last rule for a tool does not disable the tool, it makes every call to it fail closed — which is the intended way to stop an agent using a tool without tearing down its configuration.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

toolId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ruleId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

The audit log of every tool invocation made through the platform

ATH-205 (Doc 05 §5 "full audit in agent_actions table") — requires ai.view_runs and the feature.ai.agent_actions flag. One row per invocation attempt, whoever made it: the AI agent, a workflow action node, or an operator's try-it click. The row records who called, which tool, with what arguments, what the permission engine decided, who approved it when a human had to, and what came of it.

REDACTION. Arguments and results are stored REDACTED and TRUNCATED — values under keys that look like secrets are replaced with [redacted], long strings are cut, and large structures are capped. redacted_keys names the paths that were masked so a reader can tell "there was nothing here" from "there was something here and we did not keep it". Credential material from the per-workspace vault is never part of an audit row at all: it is injected downstream of the record and is not carried on the event the audit listens to.

RETENTION (PA-268). The arguments and result have an END DATE; the rest of the row does not. Past the payload retention window a daily sweep empties those two columns and stamps payload_scrubbed_at — the invocation record itself (who asked, which tool, what was decided, who approved, how it ended, when) is kept, because that record IS the audit. A workspace may also opt out of storing the payloads at all, from the first write, via settings.tools.store_action_payloads. Both states are DECLARED on the row rather than silent: see payload_scrubbed_at and payload_scrub_reason on the detail response.

Ordering is newest-first by id (ULIDs are time-ordered), which is a non-nullable column, so the cursor is stable across engines. Scoped to the workspace in the path — another tenant's actions are never visible.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
tool_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: tool_id=01j8me9fycv0q4t4c7wz8k2xt1

Only actions for this tool.

caller_kind
string
Enum: "agent" "workflow" "human"

Only actions made by this kind of caller.

status
string
Enum: "ok" "error" "denied" "pending_approval" "expired"

Only actions that ended in this state.

from
string <date-time>

Only actions that occurred at or after this instant.

to
string <date-time>

Only actions that occurred at or before this instant.

cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

One tool invocation with its redacted arguments and result

ATH-205 — requires ai.view_runs and the feature.ai.agent_actions flag. The full record: provenance, the rule that decided, the redacted arguments and result, the approver when there was one, and the outcome.

PA-268 — the payloads may legitimately be absent, and the row SAYS SO when they are: an empty arguments with a non-null payload_scrubbed_at means "there was something here and its retention window passed", not "the call carried no arguments". Always read payload_scrubbed_at before drawing a conclusion from an empty payload.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

actionId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Automation

The "do it for me" execution audit (Doc 07 §2.2, PA-220): the agent-driven runtime executes an executable guide's steps one at a time on the customer's own app, under a consent HUD, and every run and every executed step is logged. READ-ONLY here — the rows are written in-process by the execution engine (PA-218), never over HTTP. Steps record WHICH selector resolved and THAT an action happened, never a typed value (CLAUDE.md rule 3). Requires ai.view_runs and is dark below feature.automation.runs.

The audit log of every "do it for me" execution run

PA-220 (Doc 07 §2.2 — "every executed step logged (automation_runs, screenshots optional)") — requires ai.view_runs and the feature.automation.runs flag. One row per execution SESSION: the agent-driven runtime executing an executable guide's steps one at a time on the customer's own app, under a consent HUD. The row records who launched it, which guide (its title snapshotted so the record outlives the guide), the host it acted on, and how it ended.

NO TYPED VALUES (CLAUDE.md rule 3). This is the read half of an audit spine that structurally cannot hold captured input. A run's steps record WHICH field a fill touched (selector_used) and THAT a fill happened — never the text entered. A step handed to the user for manual entry because the field was masked or sensitive is flagged sensitive_manual, again with no value.

Ordering is newest-first by id (ULIDs are time-ordered), a non-nullable column, so the cursor is stable across engines. Scoped to the workspace in the path — another tenant's runs are never visible.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
guide_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: guide_id=01j8me9fycv0q4t4c7wz8k2xt1

Only runs that executed this guide.

status
string
Enum: "running" "completed" "aborted" "failed"

Only runs that ended in this state.

initiator_kind
string
Enum: "visitor" "member" "agent"

Only runs launched by this kind of initiator.

from
string <date-time>

Only runs that started at or after this instant.

to
string <date-time>

Only runs that started at or before this instant.

cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

One execution run with its executed steps

PA-220 — requires ai.view_runs and the feature.automation.runs flag. The full run with its ordered steps. For each step: the action, WHICH selector resolved (selector_used), the outcome, whether it was handed to the user for manual entry (sensitive_manual), and an optional screenshot STORAGE REFERENCE — never an inline image, and never a typed value (CLAUDE.md rule 3).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

runId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Product domains the "do it for me" engine may act on

PA-219 (Doc 07 §2.2 — "only runs on customer's own product domains (workspace-verified domain allowlist)") — requires settings.security and the feature.automation.allowlist flag (404 below it). The execution engine (PA-218) may drive a page ONLY when its host has a verified row here; adding a host is not enough, ownership must be proven. This is the security boundary, enforced server-side — a client-side check is bypassable.

Each row carries the verification_token the workspace must serve at well_known_path on the host to prove control of it. Status moves pending → verified/failed via the async check.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Add a product domain (starts ownership verification)

PA-219 — requires settings.security and the feature.automation.allowlist flag. The host is normalised (lowercased, scheme/port/path stripped) and must be unique within the workspace. Verification is asynchronous: the row returns pending with a verification_token, and the workspace proves control by serving that token at well_known_path over HTTPS, after which the check flips the row to verified. Only a verified host authorises execution.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
host
required
string

The product host to allow, e.g. app.customer.com. A full URL is accepted and normalised down to its host (scheme, port and path are stripped); the result is lowercased on save.

Responses

Request samples

Content type
application/json
{
  • "host": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One allowed domain with its verification state

PA-219 — requires settings.security and the feature.automation.allowlist flag. Another tenant's id 404s under the workspace scope (rule 2).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

domainId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove an allowed domain

PA-219 — removing the row immediately withdraws execution authorisation for the host: the guard reads this table live, so the next run on that host is refused.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

domainId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Re-run the ownership verification check

PA-219 — the "check it now" endpoint. A domain is added pending BEFORE the workspace has served the token, so its first automatic check fails; once the token is in place the workspace calls this to re-check. Queues a fresh check; poll the list or show endpoint for the outcome. Idempotent and throttled.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

domainId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Guidance

Context-aware proactive guidance (Doc 07 §4, E32): the page↔knowledge map that links URL patterns to the guides relevant there. Manual pins (curated: "pin this guide to /settings/billing", optionally to one element via a selector) plus the automatic associations derived from the per-step URLs guides carry from capture. The map query is the read the nudge engine (PA-229) and the employee Sidekick (PA-230) build on. Dark below feature.guidance.page_map.

Guides pinned to URL patterns

PA-228 (Doc 07 §4) — the manual half of the page↔knowledge map: every guide a workspace has pinned to a URL pattern, newest first. A pin makes a guide relevant to the pages its url_pattern matches, optionally anchored to one element by selector (element-level pinning, Scribe parity). Requires guides.edit_any and the feature.guidance.page_map flag (404 below it).

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Pin a guide to a URL pattern

PA-228 — pin a guide so it surfaces on pages matching url_pattern. match_type chooses how the pattern is compared to a page URL: exact uses the tolerant walkthrough-player rule (origin + path equality up to a trailing slash, query subset, hash only when the pattern carries one — the same clients/walkthrough urlMatches semantics the auto map uses, so client and server agree), prefix matches a path and everything under it, glob matches a * wildcard path. selector is optional and pins the guide to one element. One pin per (guide, url_pattern) per workspace. Requires guides.edit_any and the feature.guidance.page_map flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
guide_id
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

url_pattern
required
string <= 2048 characters

The URL or path pattern to pin the guide to, e.g. /settings/billing.

match_type
string
Enum: "exact" "prefix" "glob"

How the pattern is matched against a page URL. Optional; defaults to exact when omitted.

selector
string or null <= 1024 characters

Optional CSS selector for element-level pinning.

Responses

Request samples

Content type
application/json
{
  • "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "url_pattern": "string",
  • "match_type": "exact",
  • "selector": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update a pin's pattern, match type, or selector

PA-228 — change where a pinned guide surfaces. The guide a pin points at is fixed; re-pin to move a guide to another. Another tenant's pin id 404s under the workspace scope (rule 2). Requires guides.edit_any and the feature.guidance.page_map flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pinId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
url_pattern
string <= 2048 characters
match_type
string
Enum: "exact" "prefix" "glob"
selector
string or null <= 1024 characters

Pass null to clear element-level pinning.

Responses

Request samples

Content type
application/json
{
  • "url_pattern": "string",
  • "match_type": "exact",
  • "selector": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove a pin

PA-228 — unpin a guide from a URL pattern. The AUTO association (if the guide has a step whose captured URL matches) is unaffected — only the manual pin is removed. Requires guides.edit_any and the feature.guidance.page_map flag.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

pinId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Guides relevant to a page URL

PA-228 (Doc 07 §4) — given a page URL, the guides relevant there, ranked pinned-first. This is THE read the nudge engine (PA-229) and the employee Sidekick (PA-230) build on. Two sources are merged and de-duplicated by guide: MANUAL pins whose url_pattern matches the URL (carrying the optional selector), and AUTO associations — guides with a step whose captured URL matches, using the same tolerant urlMatches rule the walkthrough player uses, so client and server agree on what "matches". A guide that is both pinned and auto-matched appears once, as pinned. AUTO entries are limited to published guides; a pinned entry carries its guide's status so the caller can decide. Requires guides.view (every member — so the Sidekick's audience is not locked out) and the feature.guidance.page_map flag (404 below it). Matching runs in PHP over the workspace's own pins and step URLs — never engine-specific SQL — so sqlite and Postgres agree.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
url
required
string <= 2048 characters

The page URL to resolve relevant guides for.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Sidekick — internal-audience knowledge search for employees

PA-230 (Doc 07 §4) — the copilot-search half of the employee Sidekick: a conversation-less, member-authed knowledge query that returns a grounded, cited answer over the workspace's internal audience ONLY. It is the standalone counterpart to the conversation-scoped agent-assist copilot (runCopilot): same retrieval + grounding + citation components, but no conversation, no draft, and no delivery.

AUDIENCE SCOPE is the correctness property. Retrieval is pinned to internal in the Action (never a caller parameter), so this surface can never return customers- or public-audience chunks, and — like every retrieval leg — it is workspace-scoped in SQL, so another tenant's internal knowledge is unreachable. Every cited source is therefore internal.

Requires ai.view_runs — an AI permission (this spends AI credits and reads internal knowledge), analyst-grade and NOT admin-only, so the internal team the Sidekick exists for can use it. Plan-gated on feature.guidance.sidekick (404 below it, rule 7). Every call is metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402. Grounding floor as the copilot: when nothing internal answers the query it declines and charges nothing rather than inventing.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
query
required
string [ 2 .. 2000 ] characters

What the employee is asking the internal knowledge base.

Responses

Request samples

Content type
application/json
{
  • "query": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

MCP

External MCP servers (Doc 05 §5, ATH-203) — Cairn as an MCP CLIENT, the opposite direction to the Cairn MCP server under Search. A workspace attaches someone else's MCP server; a discover call performs the handshake and tools/list and materialises what it finds as ordinary kind=mcp tools, so permission rules, the agent_actions audit, workflow action nodes and the agent runtime all apply unchanged. Only the remote Streamable HTTP transport is supported; stdio (which would mean running a local process per workspace) is deliberately not, and every request passes the same SSRF guard the HTTP tool builder uses. Discovered tools are ALWAYS created disabled and get no agent-tier permission rule — a third party describes its own tools and those descriptions reach an LLM prompt, so enabling one is a human decision every time. Requires integrations.manage and the feature.ai.mcp_client flag.

List the external MCP servers this workspace has attached

ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag (404 without it). Credential PAYLOADS are never included; a server reports only whether a vault credential is linked and under which key. Scoped to the workspace in the path.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Attach an external MCP server

ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag. The endpoint URL is checked against the SSRF guard's DNS-free rules before it is stored, and again with DNS resolution on every outbound call. Only the remote http transport (MCP Streamable HTTP) is accepted; stdio is refused, because running a local process per workspace is a different isolation problem than this platform solves. Creating a server discovers NOTHING — call the discover operation to see what it offers.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters
slug
string <= 60 characters

Lowercase letters, digits and hyphens. Derived from the name when omitted.

url
required
string <uri> <= 2048 characters
transport
string
Value: "http"
auth_type
string
Enum: "none" "bearer" "api_key_header"
auth_header
string or null <= 120 characters
credential_key
string or null

Vault key, in THIS workspace, whose payload is injected as auth.

timeout_seconds
integer [ 1 .. 60 ]
max_response_bytes
integer [ 4096 .. 1048576 ]
enabled
boolean

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "slug": "string",
  • "transport": "http",
  • "auth_type": "none",
  • "auth_header": "string",
  • "credential_key": "string",
  • "timeout_seconds": 1,
  • "max_response_bytes": 4096,
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

A single attached MCP server

ATH-203 — requires integrations.manage. Another workspace's serverId is a 404, never a read.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

serverId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update an attached MCP server

ATH-203 — requires integrations.manage. slug and transport are immutable: every tool discovered from this server carries the slug in its own, and those tool slugs are stable so that permission rules and audit history stay attached. Setting enabled to false is the kill switch — every tool on this server stops executing without any of them being deleted.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

serverId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 120 ] characters
url
string <uri> <= 2048 characters
auth_type
string
Enum: "none" "bearer" "api_key_header"
auth_header
string or null <= 120 characters
credential_key
string or null
timeout_seconds
integer [ 1 .. 60 ]
max_response_bytes
integer [ 4096 .. 1048576 ]
enabled
boolean

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "auth_type": "none",
  • "auth_header": "string",
  • "credential_key": "string",
  • "timeout_seconds": 1,
  • "max_response_bytes": 4096,
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Detach an MCP server and delete the tools discovered from it

ATH-203 — requires integrations.manage. The tools discovered from this server are deleted with it: a tool that can never execute again must not keep sitting in the tool list reading enabled. Their agent_actions audit history SURVIVES (the FK nulls), so the record of what the agent did outlives the tool it did it with. The linked vault credential also survives — one credential can back several connections.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

serverId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Handshake, list the server's tools, and materialise them as tool rows

ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag. Performs the MCP handshake (initialize + notifications/initialized) and tools/list, then reconciles the result into kind=mcp tool rows.

IDEMPOTENT. Re-running matches on (server, remote tool name) and UPDATES in place, so a tool keeps its id, slug, permission rules and audit history across any number of refreshes. It never duplicates a row and it NEVER sets enabled to true — a tool an admin disabled stays disabled however many times the server is re-listed.

TRUST. Discovered tools are created DISABLED with only an operator-tier permission rule, so the agent cannot call a newly discovered tool at all until someone writes a rule saying it may. A tool whose definition (name, description, argument schema) has CHANGED since it was enabled is switched back off with review_reason: definition_changed — approval attaches to what a human reviewed, not to a name the server can refill. A tool the server no longer offers is marked review_reason: removed_upstream and disabled, never deleted.

Failures (SSRF refusal, handshake error, timeout, oversized response) are 422 on url and are recorded on the server row.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

serverId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Workflows

No-code workflow builder (Doc 05 §6, ATH-191): CRUD over authored node graphs, server-side graph validation, and draft/publish. The runtime (ATH-190) triggers only PUBLISHED workflows; the editor writes a DRAFT graph and publish promotes it to the active graph the runtime reads. Requires workflows.manage; the builder itself is gated on feature.workflows.builder (Growth+).

The node palette — types + per-type config schema for the editor

ATH-191 — every node type registered on the runtime's NodeRegistry with the metadata the canvas editor renders its palette and per-node config forms from. PA-276 adds timeout_minutes to the ask_question descriptor so authored waits have a visible, bounded lifetime. Requires workflows.manage. Read-only, available on any plan.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

The pre-built workflow library, with the placeholders each one needs

ATH-194 — the first-party template library (Doc 05 §6 "template library"). Requires workflows.manage. Read-only and available on any plan, so the workflow list page can show what the Growth plan would unlock; INSTANTIATING one is the gated write.

Templates are code-defined and versioned with the application, not workspace rows: they are product content, and a shipped guard (WorkflowTemplateTest) proves every one of them still names registered node types, still names a trigger-eligible event, and still instantiates into a graph the editor's own validator accepts.

Each template declares the placeholders a workspace must fill in before the graph means anything — which segment, which tool, which message copy. Every placeholder is a string; a required one with no default must be supplied to instantiate.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Instantiate a pre-built template as a new DRAFT workflow

ATH-194 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Copies the template's graph into a new workflow in this workspace with the supplied placeholder values substituted, and returns it exactly as createWorkflow would.

The result is always a DRAFT, never published: a template is a starting point an author is expected to read, adjust and simulate, and putting a flow in front of customers stays a deliberate human act. graph is therefore null on the response and draft_graph carries the instantiated graph.

A missing required placeholder, a blank one, or a key the template does not declare is a 422 under error.details.placeholders.<key>.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

templateId
required
string <= 64 characters

The template slug from listWorkflowTemplates.

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
name
string [ 1 .. 120 ] characters

Overrides the template's default_name.

object

Placeholder key to value. Every value is a string.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "placeholders": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List workflows (cursor-paginated, newest first)

ATH-191 — requires workflows.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
status
string
Enum: "draft" "published" "archived"
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Create a workflow (starts as a draft)

ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). A graph, if supplied, is validated server-side; when omitted a minimal trigger-only starter graph is seeded so the canvas has an entry node. Requires workflows.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters
object (WorkflowTrigger)

ATH-191 — what starts the workflow (Doc 05 §6). event is a domain-event name (e.g. conversation.message_received); filters narrow it (the runtime understands keyword today).

object (WorkflowGraph)

ATH-191 — the authored graph: nodes + edges.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "trigger": {
    },
  • "graph": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Full workflow incl. the draft graph and the active (published) graph

ATH-191 — requires workflows.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update the draft (name, trigger, and/or the draft graph)

ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). A supplied graph is the DRAFT graph and is validated server-side: every node's type must be registered and its config valid, edges must reference existing nodes, exactly one trigger must exist, every node must be reachable from it, and no synchronous loop may exist that the runtime's step guard would only trip at run time. Validation failures return 422 with per-node field errors under error.details (keyed graph.nodes.<id> / graph). The active (published) graph is untouched until publish.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 120 ] characters
object (WorkflowTrigger)

ATH-191 — what starts the workflow (Doc 05 §6). event is a domain-event name (e.g. conversation.message_received); filters narrow it (the runtime understands keyword today).

object (WorkflowGraph)

ATH-191 — the authored graph: nodes + edges.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "trigger": {
    },
  • "graph": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Delete a workflow

ATH-191 — requires workflows.manage. Available on any plan so a downgraded workspace can still clean up. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Promote the draft graph to the active (published) graph

ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Re-validates the draft graph (422 on failure) then copies it to the active graph, flips status to published, and bumps version on re-publish. From here the runtime (ATH-190) triggers the workflow.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Return a published workflow to draft (stops triggering it)

ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Flips status to draft; the runtime stops triggering it. The last-published graph stays on the row so re-publishing without edits is a no-op promotion. In-flight runs finish against their own snapshot.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Dry-run the DRAFT graph against a hypothetical subject

ATH-193 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Walks the DRAFT graph (that is the point — an author sees what an unpublished change would do) against a hypothetical subject and returns the full step trace, branch by branch, with the run context after each step. The draft is re-validated first, so an invalid graph 422s with the same per-node keys the canvas already pins.

A simulation has NO side effects. It never persists a workflow run, never emits a domain event, and never reaches a real customer: every node type that could touch anything outside the run is replaced by a stand-in that reports what it WOULD do. Node types the simulator does not know how to stand in for are refused rather than executed, so a newly registered node cannot silently perform a real side effect here.

Parks are fast-forwarded rather than awaited: a delay reports "would wait 3 days" and execution continues; a question reports that it would wait for a reply and takes the scripted answers entry so both branches can be explored. outcomes scripts the branch a non-deterministic node (ai_answer, action) would take.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

workflowId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
optional
object

The trigger payload, seeded into the run context exactly as a real trigger would seed it. Condition nodes read it by dot-path (a node keyed trigger.plan reads context.trigger.plan).

object

Scripted replies for question nodes, keyed by the node's save_as. The value lands at context answers.<save_as> just as a real visitor reply would, so downstream conditions branch on it. A question with no scripted answer still advances; its answer is simply absent from context.

object

Scripted results for nodes whose real outcome cannot be known without doing the thing, keyed by node id. For ai_answer and action the value is the branch to take (answered, escalated, ok, denied, …); an unscripted node takes its documented default (answered / ok).

Responses

Request samples

Content type
application/json
{
  • "context": { },
  • "answers": {
    },
  • "outcomes": {
    }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Ticketing

Tickets + the customer portal (Doc 04 §7, ATH-214). A ticket is NOT a second entity: per the doc, "tickets = conversations with kind=ticket, plus reference number, priority, SLA timers, custom ticket fields, portal visibility". These paths therefore read and write the SAME conversations rows the Inbox tag serves, and the thread is ordinary messages — reply, assign, and state transitions all reuse the Inbox endpoints rather than duplicating them here. Requires tickets.manage; gated on feature.support.ticketing (Scale+), which 404s below the plan. The end-customer portal is a server-rendered surface on the help-center host, not an API — its identity is the ATH-096 JWT handshake session resolved to a Contact, so it carries no bearer token and appears in no path here.

Agents' ticket queue (cursor-paginated)

Requires tickets.manage; 404s below feature.support.ticketing. Returns only conversations with kind=ticket — the same rows the Inbox list serves, filtered. Ordered newest-first on the ULID primary key, which is both time-ordered and NOT NULL. The obvious alternative, last_message_at, is nullable — a freshly opened ticket has no message yet — and NULLs sort to opposite ends under sqlite and Postgres, so a cursor page would break differently in test and in production.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
state
string
Enum: "pending" "open" "snoozed" "resolved"
priority
string
Enum: "low" "normal" "high" "urgent"
assignee_id
string
breached
boolean

Only tickets whose first-response or resolution SLA has been marked breached

cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Open a ticket on behalf of a contact

Requires tickets.manage. Creates a conversation with kind=ticket, allocates the per-workspace reference, and computes the SLA due dates from the priority. The opening message, when supplied, goes through the ordinary message write path so the thread is identical to any other conversation.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
subject
required
string <= 200 characters
contact_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

priority
string
Default: "normal"
Enum: "low" "normal" "high" "urgent"
assignee_id
string or null
body
string or null <= 10000 characters

Opening message; written through the shared message path when present

portal_visible
boolean
Default: false
object

Responses

Request samples

Content type
application/json
{
  • "subject": "string",
  • "contact_id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "priority": "low",
  • "assignee_id": "string",
  • "body": "string",
  • "portal_visible": false,
  • "fields": { }
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One ticket with its SLA state

Requires tickets.manage. Scoped to the workspace in the path — a member of another workspace gets 404, never another tenant’s rows. Read the thread through the Inbox messages endpoint.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ticketId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Update priority, subject, custom fields, or portal visibility

Requires tickets.manage. Changing priority RECOMPUTES the SLA due dates from the new target, unless the request also sets them explicitly. Assignment and state transitions are deliberately absent — they ride the existing Inbox assign and state endpoints, because a ticket is a conversation.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

ticketId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
subject
string <= 200 characters
priority
string
Enum: "low" "normal" "high" "urgent"
portal_visible
boolean
object
first_response_due_at
string or null <date-time>
resolution_due_at
string or null <date-time>

Responses

Request samples

Content type
application/json
{
  • "subject": "string",
  • "priority": "low",
  • "portal_visible": true,
  • "fields": { },
  • "first_response_due_at": "2019-08-24T14:15:22Z",
  • "resolution_due_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

StatusPage

Public status page, components, incidents, and uptime monitors (Doc 04 §8, ATH-215). These paths are the ADMIN surface only — the status page a customer reads is a server-rendered, anonymous, full-page-cached surface on the help-center host (ATH-092/093), so it carries no bearer token and appears in no path here, exactly like the ATH-214 customer portal. Requires settings.manage; gated on feature.support.status_page (Scale+), which 404s below the plan. Monitors make outbound HTTP to workspace-supplied URLs and therefore run through ATH-202's SafeHttpClient/OutboundUrlGuard on every check and every redirect hop; a URL that fails the guard's DNS-free checks is rejected at authoring time with 422.

The workspace's status-page settings

Requires settings.manage; 404s below feature.support.status_page. The settings row is created on first read, so a workspace that has never touched the status page still gets a well-formed (unpublished) payload rather than a 404.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Publish or rebrand the status page

Requires settings.manage. published is the master switch for the public surface — while it is false the public page 404s on every host, which is what makes "set the page up first, announce it later" possible. Any write here rotates the public page's full-page cache version.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
published
boolean
title
string [ 1 .. 120 ] characters
description
string or null <= 2000 characters
support_url
string or null <= 2048 characters

Responses

Request samples

Content type
application/json
{
  • "published": true,
  • "title": "string",
  • "description": "string",
  • "support_url": "string"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Components in display order (cursor-paginated)

Requires settings.manage. Ordered by position then by the ULID primary key — both NOT NULL, because a cursor page ordered on a nullable column pages differently under sqlite and Postgres. Unlike the public page this returns unpublished components too.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Add a component

Requires settings.manage. A component is a thing customers recognise — API, Widget, Dashboard.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters
description
string or null <= 2000 characters
status
string
Default: "operational"
Enum: "operational" "degraded" "partial_outage" "major_outage" "maintenance"
published
boolean
Default: true
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "operational",
  • "published": true,
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename a component or set its current status

Requires settings.manage. Scoped to the workspace in the path — another tenant's component id 404s, never resolves. Setting published to false removes the component from the public page without deleting its history.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

componentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 120 ] characters
description
string or null <= 2000 characters
status
string
Enum: "operational" "degraded" "partial_outage" "major_outage" "maintenance"
published
boolean
position
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "operational",
  • "published": true,
  • "position": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove a component

Requires settings.manage. Incident impact rows referencing it are removed with it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

componentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Incident history (cursor-paginated)

Requires settings.manage. Newest-first on the ULID primary key, which is time-ordered AND NOT NULL; started_at would sort NULLs to opposite ends under sqlite and Postgres.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
status
string
Enum: "investigating" "identified" "monitoring" "resolved"
active
boolean

Only incidents that are not yet resolved

cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Declare an incident

Requires settings.manage. Opens at investigating unless a status is given, records the first update from body, and emits status.incident_opened so a workflow can notify however the workspace wants — the same detection/response split ATH-214 used for SLA breaches, rather than a bespoke notifier.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
title
required
string [ 1 .. 200 ] characters
body
required
string [ 1 .. 5000 ] characters

The first update. Required, because an incident with no explanation tells a customer nothing they did not already know.

status
string
Default: "investigating"
Enum: "investigating" "identified" "monitoring" "resolved"
impact
string
Default: "minor"
Enum: "none" "minor" "major" "critical"
published
boolean
Default: true
component_ids
Array of strings (Ulid) [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ]
started_at
string <date-time>

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "body": "string",
  • "status": "investigating",
  • "impact": "none",
  • "published": true,
  • "component_ids": [
    ],
  • "started_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One incident with its full update timeline

Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 404, never another tenant's rows.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

incidentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Edit an incident's title, impact, affected components, or visibility

Requires settings.manage. Deliberately does NOT move the lifecycle status — a status change is an UPDATE with a written explanation, so it goes through the updates endpoint. Editing an incident silently while customers watch is the failure mode a status page exists to prevent.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

incidentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
title
string [ 1 .. 200 ] characters
impact
string
Enum: "none" "minor" "major" "critical"
published
boolean
component_ids
Array of strings (Ulid) [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ]

Responses

Request samples

Content type
application/json
{
  • "title": "string",
  • "impact": "none",
  • "published": true,
  • "component_ids": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Append an update and move the incident's lifecycle

Requires settings.manage. Updates are APPEND-ONLY — the timeline a customer read yesterday must still say what it said. Posting status=resolved stamps resolved_at and, for an incident a monitor opened, releases that monitor's claim so a future outage can open a new one. Emits status.incident_updated.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

incidentId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
body
required
string [ 1 .. 5000 ] characters
status
string
Enum: "investigating" "identified" "monitoring" "resolved"

Moves the incident's lifecycle. Omit to comment without changing it.

Responses

Request samples

Content type
application/json
{
  • "body": "string",
  • "status": "investigating"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Uptime monitors (cursor-paginated)

Requires settings.manage. Newest-first on the ULID primary key.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Add an uptime monitor

Requires settings.manage. The url is workspace-supplied and therefore attacker-controllable, so it is validated with ATH-202's OutboundUrlGuard DNS-free rules at authoring time — a loopback, link-local, internal-suffix, credential-bearing, or non-http(s) URL is 422 here rather than a request made later. Resolution-time checks still run on every sweep, because DNS can change after a save. See the tag description for the residual risk.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters
url
required
string <uri> <= 2048 characters
method
string
Default: "GET"
Enum: "GET" "HEAD"
expected_status
integer [ 100 .. 599 ]
Default: 200
interval_seconds
integer [ 60 .. 86400 ]
Default: 300
timeout_seconds
integer [ 1 .. 30 ]
Default: 10
failure_threshold
integer [ 1 .. 20 ]
Default: 3
recovery_threshold
integer [ 1 .. 20 ]
Default: 2
component_id
string or null
enabled
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "method": "GET",
  • "expected_status": 200,
  • "interval_seconds": 300,
  • "timeout_seconds": 10,
  • "failure_threshold": 3,
  • "recovery_threshold": 2,
  • "component_id": "string",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Edit or pause a monitor

Requires settings.manage. A changed url is re-validated against the SSRF guard exactly as on create.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

monitorId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
name
string [ 1 .. 120 ] characters
url
string <uri> <= 2048 characters
method
string
Enum: "GET" "HEAD"
expected_status
integer [ 100 .. 599 ]
interval_seconds
integer [ 60 .. 86400 ]
timeout_seconds
integer [ 1 .. 30 ]
failure_threshold
integer [ 1 .. 20 ]
recovery_threshold
integer [ 1 .. 20 ]
component_id
string or null
enabled
boolean

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "method": "GET",
  • "expected_status": 100,
  • "interval_seconds": 60,
  • "timeout_seconds": 1,
  • "failure_threshold": 1,
  • "recovery_threshold": 1,
  • "component_id": "string",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove a monitor

Requires settings.manage. Recorded check results go with it; any incident it opened stays, because that incident is public history.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

monitorId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Recorded check results for one monitor (cursor-paginated)

Requires settings.manage. Newest-first on the ULID primary key. A check that the SSRF guard refused is recorded here as a failure carrying the guard's author-facing reason, so a workspace can see WHY its monitor never ran instead of watching silent downtime.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

monitorId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Webhooks

Outbound event delivery to workspace-registered HTTP endpoints (Doc 06 §5, ATH-251) — the OPPOSITE direction to the /hooks/* paths, which are inbound receivers for Stripe, Twilio and Meta. Requires webhooks.manage; gated on feature.api.webhooks, which 404s when off. An endpoint is a workspace-supplied URL that this platform POSTs to on a retry schedule with no human in the loop, so every URL runs through ATH-202's SafeHttpClient and OutboundUrlGuard — the DNS-free half at authoring time (422 on the url field) and the resolving half on every single attempt, which is what catches a host that is public today and private tomorrow. Bodies carry an HMAC-SHA256 signature over a timestamp and the raw body; the verification recipe, the header format and the replay window are documented for customers in docs/webhooks.md.

List subscribable event types (ability: webhooks:read)

ATH-253 — the deliverable event catalog, derived at runtime from the events that implement PubliclyDeliverable (ATH-251). An integration builder renders this as the event picker, so a name that is not here cannot be subscribed to and a name that is here is guaranteed to be able to arrive.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

List this workspace's webhook endpoints (ability: webhooks:read)

ATH-253 — cursor-paginated, newest first. An iPaaS platform needs this to RECONCILE: a Zap deleted while Cairn was unreachable leaves an endpoint nobody will ever unsubscribe, and without a list call the only way to find it is the portal. The signing secret is never included — it is returned exactly once, by the create call.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "meta": {
    },
  • "data": [
    ]
}

Subscribe a URL to events (ability: webhooks:write)

ATH-253 — the subscribe half of a REST hook. Creates an ATH-251 endpoint and its subscriptions in one call and returns the HMAC signing secret EXACTLY ONCE, in this response body. A trigger implementation is expected to keep that secret with the subscription and verify Cairn-Signature on every delivery; there is no reveal endpoint, because "show me the secret again" and "rotate the secret" are the same operation from a security standpoint and only one of them leaves the existing verifier working.

url is validated by ATH-202's DNS-free SSRF guard here and by the resolving guard on every delivery attempt.

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
required
string <= 2048 characters

An http(s) URL that is not loopback, link-local, private, cloud-metadata, or an internal suffix. Validated without DNS at authoring time and with DNS on every delivery attempt.

description
string or null <= 200 characters

Free text. An iPaaS client should identify itself and the automation here.

events
required
Array of strings [ 1 .. 50 ] items

Event type names from /webhook-event-types. An unknown name is a 422.

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "description": "string",
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Unsubscribe and delete an endpoint (ability: webhooks:write)

ATH-253 — the unsubscribe half of a REST hook. Subscriptions, deliveries and attempts cascade. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
webhookId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Event types an endpoint may subscribe to

ATH-251 — requires webhooks.manage. The catalog is DERIVED from the domain events that have opted in to public delivery, never hand-maintained, so it cannot drift from what is actually deliverable. Subscribing to anything outside it is a 422.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Registered endpoints (cursor-paginated)

ATH-251 — requires webhooks.manage. Newest-first on the ULID primary key. The signing secret is never included; it is returned exactly once, by the create call.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Register an endpoint and mint its signing secret

ATH-251 — requires webhooks.manage. The url is workspace-supplied and therefore attacker-controllable, so it is validated with ATH-202's OutboundUrlGuard DNS-free rules here (loopback, link-local, cloud-metadata, internal suffixes, credential-bearing and numerically-encoded hosts, and non-http(s) schemes are all 422 on the url field) and re-checked WITH DNS resolution on every delivery attempt.

THE RESPONSE CARRIES THE SIGNING SECRET, and it is the only time it is ever returned. It is stored encrypted at rest and there is deliberately no reveal endpoint — a lost secret is rotated, not recovered.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
url
required
string <= 2048 characters

An http(s) URL that is not loopback, link-local, private, cloud-metadata, or an internal suffix. Validated without DNS here and with DNS on every delivery.

description
string or null <= 200 characters
events
required
Array of strings [ 1 .. 50 ] items

Event type names from the webhook-event-types catalog

status
string
Default: "enabled"
Enum: "enabled" "disabled"

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "description": "string",
  • "events": [
    ],
  • "status": "enabled"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

One endpoint with its subscriptions and health

ATH-251 — requires webhooks.manage. Includes the failure counter and, when it was switched off automatically, the reason.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

endpointId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Edit subscriptions, pause, or re-enable an endpoint

ATH-251 — requires webhooks.manage. A changed url is re-validated against the SSRF guard exactly as on create. Setting status back to enabled after an automatic disable clears the failure counter and the recorded reason; it is the only way an auto-disabled endpoint resumes, because a redelivery must not quietly re-enable a host we have evidence is broken.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

endpointId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
url
string <= 2048 characters
description
string or null <= 200 characters
events
Array of strings [ 1 .. 50 ] items
status
string
Enum: "enabled" "disabled"

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "description": "string",
  • "events": [
    ],
  • "status": "enabled"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove an endpoint

ATH-251 — requires webhooks.manage. Its subscriptions, deliveries and attempt history go with it; nothing is delivered to it afterwards.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

endpointId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Delivery log (cursor-paginated)

ATH-251 — requires webhooks.manage. Newest-first on the ULID primary key. One row per event per endpoint, carrying the outcome, the attempt count and the last status code or error; the per-attempt detail is on the single-delivery route. A delivery refused by the SSRF guard appears here as a failure with the reason, never as a silent success.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
endpoint_id
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: endpoint_id=01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

status
string
Enum: "pending" "succeeded" "failed"

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

One delivery with every attempt and the exact body sent

ATH-251 — requires webhooks.manage. body is the verbatim bytes that were signed and sent, so a customer debugging a signature mismatch can compare against what their server received. Each attempt carries its status code, duration and error.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

deliveryId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Replay a delivery

ATH-251 — requires webhooks.manage. Creates a NEW delivery that replays the original's exact bytes; the original keeps its status and attempt history, because that history is the evidence behind an auto-disable and must not be erased by a retry click.

The replay carries the ORIGINAL Idempotency-Key, so a receiver that already processed the event will correctly ignore it — redelivery exists for the case where the endpoint never got it.

409 when the endpoint is disabled or the delivery is still being attempted. Re-enabling is a separate, explicit PATCH.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

deliveryId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Sso

ATH-260, Doc 01 §4 — Cairn as a SAML 2.0 SERVICE PROVIDER. A workspace federates login to its own IdP (Okta, Entra, Ping, Google Workspace). This is the mirror image of OAuthApps: there Cairn is the authorization server a third party trusts, here Cairn is the relying party that trusts a customer's IdP.

Assertion handling is NOT hand-rolled. XML canonicalisation and signature verification run through onelogin/php-saml (SAML-Toolkits, MIT), because hand-written SAML is a documented source of critical authentication bypasses and every one of them is a bug in code that looked correct.

WORKSPACE RESOLUTION IS THE TENANCY BOUNDARY, and it is decided BEFORE any XML is parsed. Every workspace gets its own ACS URL and its own SP entity ID, both containing the workspace id, so the tenant comes from the ROUTE — never from a claim inside the assertion, and never from the email domain. An email domain is a DNS fact about a mail server, not a statement about which Cairn workspace someone belongs to, and two workspaces may legitimately share one.

On top of the route binding, FIVE independent checks must all agree with the addressed workspace's stored configuration before anyone is logged in: the signature must verify against THAT workspace's IdP certificate; the assertion Issuer must equal THAT workspace's configured IdP entity id; SubjectConfirmationData/@Recipient must name the ACS actually being called; Destination must name THAT workspace's ACS, matched strictly rather than by prefix; and the Audience restriction must equal THAT workspace's SP entity id, which is derived from the workspace id and so is unique per tenant.

Two tenants with different IdPs are separated by the signature alone. Two tenants sharing ONE IdP and ONE certificate are separated three times over — recipient, destination and audience each refuse independently — so no single test can attribute that refusal to one of them. Two tests split the matrix instead: an assertion addressed to this workspace but scoped to another isolates the audience, and one scoped to this workspace but addressed to another isolates the endpoint binding.

That count is five rather than four because breaking the guards one at a time found a check that reading the settings had missed: Recipient is validated in php-saml's SubjectConfirmation loop and is NOT governed by relaxDestinationValidation.

SP metadata XML for this workspace

ATH-260 — the document a customer uploads into their IdP. It is per-workspace, not per-installation, and the entityID plus the ACS URL both carry the workspace id. That is what makes the Audience restriction a tenancy check rather than a formality.

Unauthenticated on purpose: it contains only public SP facts (entity id, ACS URL, binding, NameID format) and the IdP fetches it before any trust exists in either direction.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

SP-initiated login — redirect to the workspace's IdP

ATH-260 — mints an AuthnRequest, persists its id server-side, and redirects to the IdP with a RelayState handle.

The pending request is stored in the DATABASE, not the session. A browser returning from the IdP arrives on a cross-site POST, which does not carry SameSite=Lax cookies, so a session-backed request store would break exactly the flow it exists to secure and invite somebody to "fix" it by dropping the InResponseTo check. RelayState carries an opaque single-use handle; the ACS resolves it to the stored request id and hands THAT to the library, which then asserts it against the signed document.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
redirect_to
string

Portal path to land on after login. Validated as a relative path — an absolute URL is rejected rather than followed, so this cannot be turned into an open redirect.

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Assertion Consumer Service — consume a SAML response and log in

ATH-260 — the security core of this ticket. Accepts the IdP's SAMLResponse form POST (HTTP-POST binding) and establishes a portal session, or refuses.

No Idempotency-Key: the caller is an IdP's browser redirect, not an Cairn client, and this endpoint's replay semantics are the opposite of replay-safe. A repeated assertion MUST be refused, never served a cached success.

EVERY one of the following must hold, and each has a test that breaks the guard and confirms the refusal turns red:

the XML signature verifies against the certificate stored for THIS workspace; NotBefore and NotOnOrAfter bracket the current time; Audience equals this workspace's SP entity id; Issuer equals this workspace's configured IdP entity id; for SP-initiated flows InResponseTo matches a pending request this workspace issued and has not consumed; for IdP-initiated flows the connection must opt in AND the response must carry no InResponseTo at all; the assertion ID has not been seen before for this workspace; and the subject resolves to a member of THIS workspace, or the connection permits just-in-time provisioning.

Every refusal is the same 403 with the same body. Somebody probing an ACS should not be able to tell an unknown workspace from a bad signature from a replayed assertion; the real reason goes to the workspace's audit log.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/x-www-form-urlencoded
required
SAMLResponse
required
string

Base64-encoded samlp:Response.

RelayState
string or null

Opaque single-use handle this SP issued at startSamlLogin, echoed back by the IdP. Absent on an IdP-initiated flow, which is only accepted when the connection opts in. It is never a URL — an IdP-supplied redirect target is an open redirect with extra steps.

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Read this workspace's SAML connection

ATH-260 — requires settings.security. The IdP certificate is never returned; a SHA-256 fingerprint stands in for it, so an admin can confirm WHICH certificate is loaded without the response becoming a copy of it.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Create or replace the SAML connection

ATH-260 — requires settings.security. The certificate goes to the encrypted vault (CLAUDE.md rule 10) and never comes back.

enforced is the lockout-relevant field and it is NOT freely settable. It may only be turned on once this connection has completed at least one successful login, which is recorded on the row. Enforcement is a promise that the IdP works, and the only evidence that an IdP works is that somebody used it.

Two further guards make total lockout unreachable: workspace owners are never subject to enforcement, and the last remaining owner can never be deprovisioned by SCIM. See deprovisionScimUser.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema: application/json
required
idp_entity_id
required
string <= 1024 characters
idp_sso_url
required
string <uri> <= 2048 characters
idp_certificate
required
string

The IdP's X.509 signing certificate, PEM or bare base64. Write-only — it is stored in the encrypted vault and never read back. Validated as a parseable certificate at write time, because the alternative is discovering it was unparseable during somebody's first login attempt.

idp_slo_url
string or null <uri> <= 2048 characters
enforced
boolean
Default: false
allow_idp_initiated
boolean
Default: false
jit_provisioning
boolean
Default: false
default_role
string
Default: "viewer"

Responses

Request samples

Content type
application/json
{
  • "idp_entity_id": "string",
  • "idp_sso_url": "http://example.com",
  • "idp_certificate": "string",
  • "idp_slo_url": "http://example.com",
  • "enforced": false,
  • "allow_idp_initiated": false,
  • "jit_provisioning": false,
  • "default_role": "viewer"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Remove the SAML connection

ATH-260 — requires settings.security. Deleting the connection also drops enforcement, the vaulted certificate and every pending auth request. Members fall back to password login immediately; this is the deliberate manual half of the lockout escape hatch.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Scim

ATH-260, Doc 01 §4 — SCIM 2.0 (RFC 7643 / RFC 7644) user and group provisioning. The customer's IdP is the system of record for who may enter the workspace; Cairn is the downstream target.

THESE PAYLOADS ARE NOT CAIRN-SHAPED AND DELIBERATELY SO. SCIM defines its own resource schemas, its own ListResponse envelope, its own PATCH operation format, its own application/scim+json media type and its own error body (urn:ietf:params:scim:api:messages:2.0:Error). Off-the-shelf IdP SCIM clients are not configurable — Okta and Entra send what the RFC says and parse what the RFC says. Reshaping any of it into the house cursor-pagination and error.code envelope would produce an endpoint that is elegant here and non-functional in every product that would actually call it. This mirrors the exception already granted to /oauth/token for RFC 6749 §5.2 errors, and for the same reason.

What is NOT re-invented is the permission vocabulary. A SCIM Group maps onto a role from the existing RBAC catalog (PermissionCatalog::roles()) — there is no parallel SCIM role list. The mapping is capped at admin: an IdP group may never confer owner, because owner carries billing.manage and workspace.delete, and ownership is a commercial relationship rather than a directory fact. That ceiling is the same reasoning ATH-252 applied to OAuth consent.

DEPROVISIONING REVOKES, it does not merely flag. See deprovisionScimUser for the exact scope.

Bearer tokens the IdP uses to reach this workspace's SCIM endpoints

ATH-260 — requires settings.security. Tokens are stored as a SHA-256 digest, exactly like ATH-250 API keys and ATH-252 client secrets. The plaintext existed once, in the create response, and is not recoverable here or anywhere else; the short non-secret prefix is what lets an admin tell two tokens apart.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Mint a SCIM bearer token

ATH-260 — requires settings.security. The response is the only place the plaintext token ever exists outside the IdP.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
name
required
string <= 120 characters
expires_at
string or null <date-time>

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "expires_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "id": "01j8me9fycv0q4t4c7wz8k2xt1",
  • "name": "string",
  • "token_prefix": "string",
  • "last_used_at": "2019-08-24T14:15:22Z",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "revoked_at": "2019-08-24T14:15:22Z",
  • "created_at": "2019-08-24T14:15:22Z",
  • "token": "string"
}

Revoke a SCIM bearer token

ATH-260 — requires settings.security. Stamps revoked_at rather than deleting the row, so the audit trail survives the revocation. Idempotent — revoking twice keeps the first timestamp.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

tokenId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

IdP group to Cairn role mappings

ATH-260 — requires settings.security. Roles come from the existing RBAC catalog; owner is not assignable from a directory group.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Replace the whole group-to-role mapping set

ATH-260 — requires settings.security. Whole-set replace rather than per-item CRUD: the mapping table is small, an admin reasons about it as one policy, and a partial update is how you end up with a stale row nobody remembers granting.

role must be a key of the RBAC catalog and may not be owner. A directory group conferring billing.manage and workspace.delete is not a mapping anyone should be able to make by typing a group name.

Authorizations:
bearerAuth
path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects (ScimGroupMapping) <= 100 items

Responses

Request samples

Content type
application/json
{
  • "mappings": [
    ]
}

Response samples

Content type
application/json
{
  • "data": [
    ]
}

RFC 7643 §5 service provider capabilities

ATH-260 — what this SCIM implementation supports. IdPs read it during connector setup to decide whether to send PATCH or PUT and whether to attempt filtering.

security: [] means only that this path does not use the house bearer scheme. It still requires a workspace SCIM bearer token, like every other path under /scim/v2.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "documentationUri": "string",
  • "patch": {
    },
  • "bulk": {
    },
  • "filter": {
    },
  • "changePassword": {
    },
  • "sort": {
    },
  • "etag": {
    },
  • "authenticationSchemes": [
    ],
  • "meta": {
    }
}

List provisioned users

ATH-260 — RFC 7644 §3.4.2. Supports the one filter every IdP actually sends, userName eq "...", which is how a connector decides between create and update. Pagination is SCIM's 1-based startIndex and count, not the house cursor convention, because the client is an off-the-shelf connector that will not learn a new one.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
filter
string
startIndex
integer >= 1
Default: 1
count
integer [ 0 .. 200 ]
Default: 100

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "totalResults": 0,
  • "startIndex": 0,
  • "itemsPerPage": 0,
  • "Resources": [
    ]
}

Provision a user into the workspace

ATH-260 — RFC 7644 §3.3. Creates or re-attaches an Cairn user and gives them a membership in THIS workspace.

This does NOT open a second membership path. It resolves the user by email and then goes through the same ProvisionWorkspaceMember action the invitation-accept flow uses, so a SCIM-created member is indistinguishable from an invited one in workspace_members and inherits every downstream behaviour, including ATH-030 per-member overrides.

No Idempotency-Key: the caller is an IdP connector that has never heard of the header. SCIM's own idempotency is the userName uniqueness check, which answers 409 on a duplicate as RFC 7644 §3.3 requires.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema:
required
schemas
required
Array of strings
id
string
externalId
string or null

The IdP's own identifier for this person.

userName
required
string

Treated as the email address, which is what Okta, Entra and Google Workspace all send. It is the join key to an Cairn user.

object
displayName
string or null
Array of objects
active
boolean

False means DEPROVISIONED, not dormant. See deprovisionScimUser for what that revokes.

Array of objects

Read-only echo of the effective Cairn role. Writes here are ignored — role comes from group mapping, so that there is one path to a role rather than two that can disagree.

Responses

Request samples

Content type
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ]
}

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ],
  • "meta": {
    }
}

Read one provisioned user

ATH-260 — RFC 7644 §3.4.1.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimUserId
required
string

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ],
  • "meta": {
    }
}

Replace a provisioned user

ATH-260 — RFC 7644 §3.5.1. Setting active to false here is a DEPROVISION and revokes access; the scope is documented once, on deprovisionScimUser.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimUserId
required
string
Request Body schema:
required
schemas
required
Array of strings
id
string
externalId
string or null

The IdP's own identifier for this person.

userName
required
string

Treated as the email address, which is what Okta, Entra and Google Workspace all send. It is the join key to an Cairn user.

object
displayName
string or null
Array of objects
active
boolean

False means DEPROVISIONED, not dormant. See deprovisionScimUser for what that revokes.

Array of objects

Read-only echo of the effective Cairn role. Writes here are ignored — role comes from group mapping, so that there is one path to a role rather than two that can disagree.

Responses

Request samples

Content type
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ]
}

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ],
  • "meta": {
    }
}

Partially update a provisioned user

ATH-260 — RFC 7644 §3.5.2. This is how Okta and Entra actually deactivate somebody: replace on the active path with false. That single operation is the deprovision trigger.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimUserId
required
string
Request Body schema:
required
schemas
required
Array of strings
required
Array of objects

Capital O. RFC 7644 §3.5.2, also not a typo.

Responses

Request samples

Content type
{
  • "schemas": [
    ],
  • "Operations": [
    ]
}

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "userName": "string",
  • "name": {
    },
  • "displayName": "string",
  • "emails": [
    ],
  • "active": true,
  • "roles": [
    ],
  • "meta": {
    }
}

Deprovision a user — revoke workspace access

ATH-260 — RFC 7644 §3.6. THIS ACTUALLY REVOKES, which is the whole point of wiring an IdP to a SaaS product. The exact scope, stated once so nobody has to infer it:

REVOKED ALWAYS, scoped to this workspace — the workspace_members row is removed, and the tenancy middleware consults that table on EVERY request, so a live portal session and a Sanctum PAT both stop reaching this workspace on the very next request rather than at session expiry. Any pending invitation for that address is deleted. Every ATH-252 OAuth install this user granted in this workspace is revoked through RevokeOAuthInstall, which cascades to the access tokens that install issued — that one matters because an install's ceiling was derived from the granting user's permissions, and they no longer have any.

REVOKED ADDITIONALLY, only when this was the user's LAST membership anywhere — their portal sessions are deleted and all their Sanctum PATs are dropped. Those credentials are user-global rather than workspace-scoped, so destroying them because ONE workspace deprovisioned would let workspace A sign the user out of workspace B.

NOT REVOKED — workspace API keys (ATH-250). A workspace key is the workspace's credential, issued under api_keys.manage and outliving any individual member. Killing a server integration because a person left is an outage, not a security control.

REVALIDATED — content owner_id (PA-253, now fixed). SCIM deprovisioning and manual removal both route through the same RemoveWorkspaceMember action and emit the same WorkspaceMemberRemoved event, and a listener on it nulls any content owner_id that pointed at the departing member so a review never comes due against somebody with no access. The nulled content is surfaced, not hidden — it is findable with the unowned filter on the review queue. A daily reconciliation (cairn:reconcile-content-owners) is the backstop for any removal path that reaches the database without emitting the event, which matters now that an IdP can deprovision in bulk and unattended. verified_by is deliberately left untouched: a past verification by a departed colleague is a true historical fact.

The last remaining owner cannot be deprovisioned — that is the third layer of the lockout guard described on putSsoConnection.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimUserId
required
string

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "status": "string",
  • "scimType": "string",
  • "detail": "string"
}

List groups

ATH-260 — RFC 7644 §3.4.2 over Group resources.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

query Parameters
filter
string
startIndex
integer >= 1
Default: 1
count
integer [ 0 .. 200 ]
Default: 100

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "totalResults": 0,
  • "startIndex": 0,
  • "itemsPerPage": 0,
  • "Resources": [
    ]
}

Create a group and bind it to an Cairn role

ATH-260 — the group's displayName selects a role by matching a configured mapping. An unmapped group is accepted and stored but confers nothing, which is the safe direction: an IdP pushing its whole group tree must not be able to invent privileges by naming a group owner.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

Request Body schema:
required
schemas
required
Array of strings
id
string
externalId
string or null
displayName
required
string

Matched against the configured group-to-role mappings. An unmatched name is stored and confers nothing.

Array of objects

Responses

Request samples

Content type
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "displayName": "string",
  • "members": [
    ]
}

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "displayName": "string",
  • "members": [
    ],
  • "meta": {
    }
}

Read one group

ATH-260 — RFC 7644 §3.4.1 over a Group resource.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimGroupId
required
string

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "displayName": "string",
  • "members": [
    ],
  • "meta": {
    }
}

Add or remove group members

ATH-260 — RFC 7644 §3.5.2 against the members path. Adding a member applies the group's mapped role to that member; removing one returns them to the workspace default role.

Group membership changes NEVER create or destroy a workspace membership — that is the Users endpoint's job — so a group PATCH cannot be used to smuggle somebody into a workspace.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimGroupId
required
string
Request Body schema:
required
schemas
required
Array of strings
required
Array of objects

Capital O. RFC 7644 §3.5.2, also not a typo.

Responses

Request samples

Content type
{
  • "schemas": [
    ],
  • "Operations": [
    ]
}

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "id": "string",
  • "externalId": "string",
  • "displayName": "string",
  • "members": [
    ],
  • "meta": {
    }
}

Delete a group

ATH-260 — removes the group and returns its members to the workspace default role. It does NOT remove anyone from the workspace; losing a role is not losing access.

path Parameters
workspaceId
required
string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$
Examples: 01j8me9fycv0q4t4c7wz8k2xt1

Crockford base32 ULID; Laravel emits lowercase, both cases accepted

scimGroupId
required
string

Responses

Response samples

Content type
application/scim+json
{
  • "schemas": [
    ],
  • "status": "string",
  • "scimType": "string",
  • "detail": "string"
}