Cairn API (0.1.0)

Download OpenAPI specification:

Cairn customer APIs and installation contracts. Workspace API keys authorize operations tagged PublicApi with their documented abilities. Portal settings contracts require an authenticated customer session and the stated permissions. Send Idempotency-Key on POST requests and X-Workspace-Id where documented. All errors use the error envelope. API keys and signing secrets belong on your server.

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": {
    }
}

Record contact events from your back end (ability: events:write)

PA-394 — server-side contact event ingestion: record what only your back end knows (first_order_placed, plan_upgraded) so segments (event_count, event_window) and experience rules (event_window) can match on it. Up to 100 events per call. Name each contact by contact_id, external_id or email — the most specific one given wins — and contacts are never created here: upsert them with POST /contacts first.

A malformed batch — over 100 events, a name outside ^[a-z][a-z0-9_.:-]{0,63}$ (or starting cairn./cairn:), properties beyond 20 flat scalar keys / 500-character strings / 4 KB, no contact identifier, an occurred_at older than 30 days or in the future — is 422 and writes nothing. A well-formed event naming a contact this workspace does not have is reported by its index in rejected (contact_not_found) and the rest are written; another workspace's contact is indistinguishable from an unknown one. Retry with the same Idempotency-Key to get the first answer back without writing twice. Behind feature.api.public_v1 and feature.contacts.event_tracking (404 below either).

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects [ 1 .. 100 ] items

Responses

Request samples

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

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 experiences (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — cursor-paginated over ULID id descending, same contract as /contacts. Filter by type and status. Identity and status only: the draft, its steps and its rules are authoring data and stay on the workspace API.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
type
string
Enum: "tour" "program" "launcher" "banner" "announcement" "survey" "tracker"
status
string
Enum: "draft" "published" "archived"

Responses

Response samples

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

Read one experience (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — the same summary shape as the list. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
experienceId
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": {
    }
}

Per-person progress through one experience (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6, E2-9) — the rows the portal's per-person table reads (ExperienceProgressRow), newest first, cursor-paginated. Sessioned types (tour, program, survey) emit one row per session; session-less types (launcher, banner, announcement) emit one row per experience_seen marker — session_id is the marker id, version_id is null. Filter by state and contact_id.

Authorizations:
bearerAuth
path Parameters
experienceId
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 .. 200 ]
Default: 50
state
string
Enum: "active" "completed" "ended"

active, completed (ended with reason completed), or ended (ended for any other reason).

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
{
  • "data": [
    ],
  • "meta": {
    }
}

One contact's onboarding picture (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — every experience this contact has a ledger row for: a session (tours, programs, surveys) or a seen marker (launchers, banners, announcements), collapsed to ONE state per experience with the same precedence the rules engine uses (active > completed > dismissed > seen). Experiences the contact has never met are not listed — "unseen" is the absence of a row. Another workspace's contact is 404.

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": [
    ]
}

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": {
    }
}

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"
}

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": {
    }
}

Contacts

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": {
    }
}

Record contact events from your back end (ability: events:write)

PA-394 — server-side contact event ingestion: record what only your back end knows (first_order_placed, plan_upgraded) so segments (event_count, event_window) and experience rules (event_window) can match on it. Up to 100 events per call. Name each contact by contact_id, external_id or email — the most specific one given wins — and contacts are never created here: upsert them with POST /contacts first.

A malformed batch — over 100 events, a name outside ^[a-z][a-z0-9_.:-]{0,63}$ (or starting cairn./cairn:), properties beyond 20 flat scalar keys / 500-character strings / 4 KB, no contact identifier, an occurred_at older than 30 days or in the future — is 422 and writes nothing. A well-formed event naming a contact this workspace does not have is reported by its index in rejected (contact_not_found) and the rest are written; another workspace's contact is indistinguishable from an unknown one. Retry with the same Idempotency-Key to get the first answer back without writing twice. Behind feature.api.public_v1 and feature.contacts.event_tracking (404 below either).

Authorizations:
bearerAuth
header Parameters
Idempotency-Key
string <= 255 characters
Request Body schema: application/json
required
required
Array of objects [ 1 .. 100 ] items

Responses

Request samples

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

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.

Help-center JWT handshake settings (shared secret, sign-in URL)

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. sign_in_url (design v2 S79) is where the private help centre's "Sign in" sends a reader, and return_url where the customer's app sends them back.

Authorizations:
bearerAuth
path Parameters
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 where the private help centre's "Sign in" sends a reader

Design v2 S79. "Sign in" sends the reader to sign_in_url with return_to added: the PATH on the help centre they wanted (/ when none), never an address. The customer's app signs them in, mints the visitor JWT and redirects to return_url — a fixed address it knows, never one taken from the request — adding token=<jwt> and return=<return_to>; the help centre lands them on that path. So a token only ever goes to the help centre, whatever a link carries. An https page of the customer's own (http only for a loopback host while developing), with no return_to of its own, and never a Cairn host. Needs the signing secret: a sign-in page without one answers 422, because no reader could come back signed in. null removes it, and the page goes back to saying where to sign in from. Audited. Behind feature.helpcenter.sign_in_url (404 when 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
sign_in_url
required
string or null <uri> <= 2048 characters

Responses

Request samples

Content type
application/json
{}

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": {
    }
}

Inbox

Conversations, messages, routing, and the email channel (Doc 04 §4–6).

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": {
    }
}

Widget

The public widget surface (Doc 04 §2). These endpoints carry no session: a widget key or signed visitor token names the tenant.

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": {
    }
}

Widget identity configuration metadata (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. PA-426 — ordinary reads never return the secret. Store the secret securely on your server when generating or rotating it. rotated_at is when the current secret was generated; null for a secret made before that was recorded, or when none exists.

Authorizations:
bearerAuth
path Parameters
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, and visitors verified under the old one read as unverified until it does. PA-380 — recorded in the audit log (widget.identity_secret_rotated, never the secret itself) with the time it happened. PA-426 — the response has Cache-Control: no-store. The same Idempotency-Key replays encrypted delivery material for 24 hours without rotating again. Authorization, workspace membership and security requirements are checked again before each replay.

Authorizations:
bearerAuth
path Parameters
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": {
    }
}

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": {
    }
}

Experiences

In-app experiences (Doc 12): onboarding programs (sectioned checklists of tours, guides, articles, links and events), authored tours, and — Phase 2 — launchers, banners, announcements and surveys. The widget pulls a server-DECIDED set per visitor per page (start/hide rules, frequency, priority, single slot are applied server-side); progress is a contact-keyed ledger that is never consent-gated (D4); published versions are immutable so a running session is never changed underneath a user (D3). Dark below feature.experiences.core.

List experiences (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — cursor-paginated over ULID id descending, same contract as /contacts. Filter by type and status. Identity and status only: the draft, its steps and its rules are authoring data and stay on the workspace API.

Authorizations:
bearerAuth
query Parameters
cursor
string
limit
integer [ 1 .. 100 ]
Default: 25
type
string
Enum: "tour" "program" "launcher" "banner" "announcement" "survey" "tracker"
status
string
Enum: "draft" "published" "archived"

Responses

Response samples

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

Read one experience (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — the same summary shape as the list. Another workspace's id is 404, never 403.

Authorizations:
bearerAuth
path Parameters
experienceId
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": {
    }
}

Per-person progress through one experience (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6, E2-9) — the rows the portal's per-person table reads (ExperienceProgressRow), newest first, cursor-paginated. Sessioned types (tour, program, survey) emit one row per session; session-less types (launcher, banner, announcement) emit one row per experience_seen marker — session_id is the marker id, version_id is null. Filter by state and contact_id.

Authorizations:
bearerAuth
path Parameters
experienceId
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 .. 200 ]
Default: 50
state
string
Enum: "active" "completed" "ended"

active, completed (ended with reason completed), or ended (ended for any other reason).

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
{
  • "data": [
    ],
  • "meta": {
    }
}

One contact's onboarding picture (ability: experiences:read)

Doc 12 §4.4 R16(b) (E2-6) — every experience this contact has a ledger row for: a session (tours, programs, surveys) or a seen marker (launchers, banners, announcements), collapsed to ONE state per experience with the same precedence the rules engine uses (active > completed > dismissed > seen). Experiences the contact has never met are not listed — "unseen" is the absence of a row. Another workspace's contact is 404.

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": [
    ]
}

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": {
    }
}

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 — the handoff page

PA-351 (design S34) — renders the handoff interstitial: which workspace is about to be handed to which identity provider (named by host, never by logo), the promise that the password never reaches Cairn, one action, and the way out if this is not the person's company. Nothing is minted here; the action links to /login/continue, which does the SAML work. An unconfigured workspace renders the same page in its fallback state (the password form is one link away) with the 404 status ATH-260 chose, so "no plan" and "no config" stay indistinguishable.

path Parameters
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

Carried into the continue link after the same relative-path validation.

Responses

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. PA-351 moved it one step behind the handoff page above; the contract is otherwise unchanged.

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 for a previously linked account, or refuses. Unbound identities require an emailed ownership confirmation, including local MFA where configured, before this tenant IdP may authenticate the global account.

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": {
    }
}

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.

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.

Requires a workspace SCIM bearer token, like every other path under /scim/v2. The token is distinct from Cairn personal/API-key authentication.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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.

Authorizations:
scimAuth
path Parameters
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"
}