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.
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.
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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
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. |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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).
| Idempotency-Key | string <= 255 characters |
| 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. |
{- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "attributes": { }
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string or null |
| phone | string or null |
| external_id | string or null |
object |
{- "name": "string",
- "phone": "string",
- "external_id": "string",
- "attributes": { }
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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).
| Idempotency-Key | string <= 255 characters |
required | Array of objects [ 1 .. 100 ] items |
{- "events": [
- {
- "name": "string",
- "contact_id": "string",
- "external_id": "string",
- "email": "user@example.com",
- "properties": {
- "property1": "string",
- "property2": "string"
}, - "occurred_at": "2019-08-24T14:15:22Z"
}
]
}{- "data": {
- "accepted": 0,
- "rejected": [
- {
- "index": 0,
- "code": "contact_not_found",
- "message": "string"
}
]
}
}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.
| 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 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "channel": "string",
- "state": "pending",
- "kind": "conversation",
- "subject": "string",
- "reference": "string",
- "priority": "string",
- "rating": 0,
- "assignee_id": "string",
- "last_message_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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.
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| message_limit | integer [ 1 .. 200 ] Default: 50 |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "channel": "string",
- "state": "pending",
- "kind": "conversation",
- "subject": "string",
- "reference": "string",
- "priority": "string",
- "rating": 0,
- "assignee_id": "string",
- "last_message_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "messages": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "direction": "in",
- "author_type": "contact",
- "kind": "text",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
| type | string Enum: "tour" "program" "launcher" "banner" "announcement" "survey" "tracker" |
| status | string Enum: "draft" "published" "archived" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "type": "tour",
- "name": "string",
- "slug": "string",
- "status": "draft",
- "live_version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Doc 12 §4.4 R16(b) (E2-6) — the same summary shape as the list. Another workspace's id is 404, never 403.
| experienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "type": "tour",
- "name": "string",
- "slug": "string",
- "status": "draft",
- "live_version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
| experienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| cursor | string |
| limit | integer [ 1 .. 200 ] Default: 50 |
| state | string Enum: "active" "completed" "ended"
|
| 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 |
{- "data": [
- {
- "session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "contact_name": "string",
- "contact_email": "string",
- "widget_visitor_id": "string",
- "version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "active",
- "end_reason": "string",
- "progress": 0,
- "items_done": 0,
- "items_total": 0,
- "current_step_cvid": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "last_url": "string"
}
], - "meta": {
- "next_cursor": "string"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "experience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "type": "tour",
- "state": "seen",
- "session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "progress": 0,
- "items_done": 0,
- "items_total": 0,
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "seen_at": "2019-08-24T14:15:22Z",
- "dismissed_at": "2019-08-24T14:15:22Z"
}
]
}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.
{- "data": [
- {
- "name": "announcement.seen",
- "description": "string"
}
]
}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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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.
| Idempotency-Key | string <= 255 characters |
| 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 |
{- "url": "string",
- "description": "string",
- "events": [
- "string"
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}
}ATH-253 — the unsubscribe half of a REST hook. Subscriptions, deliveries and attempts cascade. Another workspace's id is 404, never 403.
| webhookId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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.
| Idempotency-Key | string <= 255 characters |
| query required | string [ 2 .. 500 ] characters |
| top_k | integer [ 1 .. 25 ] Default: 8 |
object |
{- "query": "string",
- "top_k": 8,
- "filters": {
- "audiences": [
- "public"
]
}
}{- "data": [
- {
- "chunk_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "document_ref": "string",
- "text": "string",
- "audience": "public",
- "score": 0,
- "similarity": 0,
- "source": {
- "kind": "guide",
- "id": "string",
- "title": "string",
- "url": "string"
}
}
], - "meta": {
- "audiences": [
- "public"
]
}
}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.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "description": "string",
- "status": "draft",
- "step_count": 0,
- "language": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "steps": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "seq": 0,
- "type": "string",
- "text": "string",
- "screenshot": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "media_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "width": 0,
- "height": 0,
- "original_encrypted": true,
- "original_deleted_at": "2019-08-24T14:15:22Z"
}, - "redactions": [
- {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}
], - "policy_redactions": [
- {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}
], - "smart_blur_suggestions": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "rect": {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}, - "category": "pii.email",
- "status": "pending_review"
}
], - "annotations": { },
- "element": {
- "selector_candidates": [
- "string"
], - "role": "string",
- "name": "string",
- "tag": "string",
- "bounding_box": {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}, - "dom_path_hash": "string",
- "nearest_landmark": "string",
- "source": "dom",
- "ancestors": [
- {
- "selectors": [
- "string"
], - "prev": [
- "string"
], - "next": [
- "string"
]
}
]
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}, - "app": {
- "id": "string",
- "name": "string"
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string",
- "capture": {
- "source": "extension",
- "platform": "macos",
- "narrated": true
}
}
}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.
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "collection_id": "string",
- "body": "string",
- "access_level": "public",
- "status": "draft",
- "position": 0,
- "source_guide_id": "string",
- "diverged_from_guide": true,
- "body_edited_since_generation": true,
- "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
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.
| 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. |
{- "access_token": "string",
- "token_type": "Bearer",
- "expires_in": 0,
- "refresh_token": "string",
- "scope": "string",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}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.
| 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 |
{- "active": true,
- "scope": "string",
- "client_id": "string",
- "token_type": "Bearer",
- "exp": 0,
- "iat": 0,
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_name": "string"
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "abilities": [
- "string"
], - "key_prefix": "string",
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
]
}Requires api_keys.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| name required | string |
| abilities required | Array of strings Abilities from the ATH-250 vocabulary ( |
| expires_at | string <date-time> |
{- "name": "string",
- "abilities": [
- "string"
], - "expires_at": "2019-08-24T14:15:22Z"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "abilities": [
- "string"
], - "key_prefix": "string",
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}, - "plaintext_key": "string"
}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.
| 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 |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "abilities": [
- "string"
], - "key_prefix": "string",
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
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. |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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).
| Idempotency-Key | string <= 255 characters |
| 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. |
{- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "attributes": { }
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string or null |
| phone | string or null |
| external_id | string or null |
object |
{- "name": "string",
- "phone": "string",
- "external_id": "string",
- "attributes": { }
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "external_id": "string",
- "verified": true,
- "attributes": { },
- "unsubscribed": true,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}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).
| Idempotency-Key | string <= 255 characters |
required | Array of objects [ 1 .. 100 ] items |
{- "events": [
- {
- "name": "string",
- "contact_id": "string",
- "external_id": "string",
- "email": "user@example.com",
- "properties": {
- "property1": "string",
- "property2": "string"
}, - "occurred_at": "2019-08-24T14:15:22Z"
}
]
}{- "data": {
- "accepted": 0,
- "rejected": [
- {
- "index": 0,
- "code": "contact_not_found",
- "message": "string"
}
]
}
}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.
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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "configured": true,
- "secret": "string",
- "sign_in_url": "string",
}
}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).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| sign_in_url required | string or null <uri> <= 2048 characters |
{
}{- "data": {
- "configured": true,
- "secret": "string",
- "sign_in_url": "string",
}
}ATH-096 — rotation invalidates every outstanding visitor JWT signed with the old secret immediately.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "secret": "string"
}
}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.
| 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 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "channel": "string",
- "state": "pending",
- "kind": "conversation",
- "subject": "string",
- "reference": "string",
- "priority": "string",
- "rating": 0,
- "assignee_id": "string",
- "last_message_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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.
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| message_limit | integer [ 1 .. 200 ] Default: 50 |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "channel": "string",
- "state": "pending",
- "kind": "conversation",
- "subject": "string",
- "reference": "string",
- "priority": "string",
- "rating": 0,
- "assignee_id": "string",
- "last_message_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "messages": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "direction": "in",
- "author_type": "contact",
- "kind": "text",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}The public widget surface (Doc 04 §2). These endpoints carry no session: a widget key or signed visitor token names the tenant.
Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "website_id": "string",
- "snippet": "string",
- "installed": true,
- "installed_at": "2019-08-24T14:15:22Z",
- "agent_instructions": "string"
}
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "configured": true,
- "rotated_at": "2019-08-24T14:15:22Z"
}
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "secret": "string",
- "rotated_at": "2019-08-24T14:15:22Z"
}
}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.
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.
| Idempotency-Key | string <= 255 characters |
| query required | string [ 2 .. 500 ] characters |
| top_k | integer [ 1 .. 25 ] Default: 8 |
object |
{- "query": "string",
- "top_k": 8,
- "filters": {
- "audiences": [
- "public"
]
}
}{- "data": [
- {
- "chunk_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "document_ref": "string",
- "text": "string",
- "audience": "public",
- "score": 0,
- "similarity": 0,
- "source": {
- "kind": "guide",
- "id": "string",
- "title": "string",
- "url": "string"
}
}
], - "meta": {
- "audiences": [
- "public"
]
}
}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.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "description": "string",
- "status": "draft",
- "step_count": 0,
- "language": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "steps": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "seq": 0,
- "type": "string",
- "text": "string",
- "screenshot": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "media_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "width": 0,
- "height": 0,
- "original_encrypted": true,
- "original_deleted_at": "2019-08-24T14:15:22Z"
}, - "redactions": [
- {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}
], - "policy_redactions": [
- {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}
], - "smart_blur_suggestions": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "rect": {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}, - "category": "pii.email",
- "status": "pending_review"
}
], - "annotations": { },
- "element": {
- "selector_candidates": [
- "string"
], - "role": "string",
- "name": "string",
- "tag": "string",
- "bounding_box": {
- "x": 0,
- "y": 0,
- "w": 0,
- "h": 0
}, - "dom_path_hash": "string",
- "nearest_landmark": "string",
- "source": "dom",
- "ancestors": [
- {
- "selectors": [
- "string"
], - "prev": [
- "string"
], - "next": [
- "string"
]
}
]
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}, - "app": {
- "id": "string",
- "name": "string"
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string",
- "capture": {
- "source": "extension",
- "platform": "macos",
- "narrated": true
}
}
}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.
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "collection_id": "string",
- "body": "string",
- "access_level": "public",
- "status": "draft",
- "position": 0,
- "source_guide_id": "string",
- "diverged_from_guide": true,
- "body_edited_since_generation": true,
- "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
| type | string Enum: "tour" "program" "launcher" "banner" "announcement" "survey" "tracker" |
| status | string Enum: "draft" "published" "archived" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "type": "tour",
- "name": "string",
- "slug": "string",
- "status": "draft",
- "live_version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Doc 12 §4.4 R16(b) (E2-6) — the same summary shape as the list. Another workspace's id is 404, never 403.
| experienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "type": "tour",
- "name": "string",
- "slug": "string",
- "status": "draft",
- "live_version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
| experienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| cursor | string |
| limit | integer [ 1 .. 200 ] Default: 50 |
| state | string Enum: "active" "completed" "ended"
|
| 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 |
{- "data": [
- {
- "session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "contact_name": "string",
- "contact_email": "string",
- "widget_visitor_id": "string",
- "version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "active",
- "end_reason": "string",
- "progress": 0,
- "items_done": 0,
- "items_total": 0,
- "current_step_cvid": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "last_url": "string"
}
], - "meta": {
- "next_cursor": "string"
}
}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.
| contactId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "experience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "type": "tour",
- "state": "seen",
- "session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "version_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "progress": 0,
- "items_done": 0,
- "items_total": 0,
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "seen_at": "2019-08-24T14:15:22Z",
- "dismissed_at": "2019-08-24T14:15:22Z"
}
]
}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.
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.
{- "data": [
- {
- "name": "announcement.seen",
- "description": "string"
}
]
}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.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}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.
| Idempotency-Key | string <= 255 characters |
| 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 |
{- "url": "string",
- "description": "string",
- "events": [
- "string"
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}
}ATH-253 — the unsubscribe half of a REST hook. Subscriptions, deliveries and attempts cascade. Another workspace's id is 404, never 403.
| webhookId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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.
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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| redirect_to | string Carried into the continue link after the same relative-path validation. |
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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| 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. |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| SAMLResponse required | string Base64-encoded |
| RelayState | string or null Opaque single-use handle this SP issued at |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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.
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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "schemas": [
- "string"
], - "documentationUri": "string",
- "patch": {
- "supported": true
}, - "bulk": {
- "supported": true,
- "maxOperations": 0,
- "maxPayloadSize": 0
}, - "filter": {
- "supported": true,
- "maxResults": 0
}, - "changePassword": {
- "supported": true
}, - "sort": {
- "supported": true
}, - "etag": {
- "supported": true
}, - "authenticationSchemes": [
- {
- "type": "string",
- "name": "string",
- "description": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| filter | string |
| startIndex | integer >= 1 Default: 1 |
| count | integer [ 0 .. 200 ] Default: 100 |
{- "schemas": [
- "string"
], - "totalResults": 0,
- "startIndex": 0,
- "itemsPerPage": 0,
- "Resources": [
- { }
]
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| 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 |
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. |
{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
]
}{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}ATH-260 — RFC 7644 §3.4.1.
| 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 |
{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| 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 |
| 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 |
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. |
{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
]
}{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| 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 |
| schemas required | Array of strings |
required | Array of objects Capital O. RFC 7644 §3.5.2, also not a typo. |
{- "schemas": [
- "string"
], - "Operations": [
- {
- "op": "string",
- "path": "string",
- "value": null
}
]
}{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "userName": "string",
- "name": {
- "givenName": "string",
- "familyName": "string",
- "formatted": "string"
}, - "displayName": "string",
- "emails": [
- {
- "value": "string",
- "primary": true,
- "type": "string"
}
], - "active": true,
- "roles": [
- {
- "value": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| 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 |
{- "schemas": [
- "string"
], - "status": "string",
- "scimType": "string",
- "detail": "string"
}ATH-260 — RFC 7644 §3.4.2 over Group resources.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| filter | string |
| startIndex | integer >= 1 Default: 1 |
| count | integer [ 0 .. 200 ] Default: 100 |
{- "schemas": [
- "string"
], - "totalResults": 0,
- "startIndex": 0,
- "itemsPerPage": 0,
- "Resources": [
- { }
]
}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.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| 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 |
{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "displayName": "string",
- "members": [
- {
- "value": "string",
- "display": "string"
}
]
}{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "displayName": "string",
- "members": [
- {
- "value": "string",
- "display": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}ATH-260 — RFC 7644 §3.4.1 over a Group resource.
| 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 |
{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "displayName": "string",
- "members": [
- {
- "value": "string",
- "display": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| 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 |
| schemas required | Array of strings |
required | Array of objects Capital O. RFC 7644 §3.5.2, also not a typo. |
{- "schemas": [
- "string"
], - "Operations": [
- {
- "op": "string",
- "path": "string",
- "value": null
}
]
}{- "schemas": [
- "string"
], - "id": "string",
- "externalId": "string",
- "displayName": "string",
- "members": [
- {
- "value": "string",
- "display": "string"
}
], - "meta": {
- "resourceType": "string",
- "created": "2019-08-24T14:15:22Z",
- "lastModified": "2019-08-24T14:15:22Z",
- "location": "string"
}
}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.
| 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 |
{- "schemas": [
- "string"
], - "status": "string",
- "scimType": "string",
- "detail": "string"
}