Download OpenAPI specification:
Cairn platform API — SINGLE SOURCE OF TRUTH for all HTTP contracts.
Rules (see repo CLAUDE.md):
Idempotency-Key header: a retry
with the same key within 24h replays the original success response instead
of re-executing; a concurrent duplicate returns 409 idempotency_conflict.
Deliberate exceptions (no header declared, enforced by the
cairn-idempotency-key Spectral rule): webhook receivers under /hooks/*
(senders carry their own event ids), /beacon (navigator.sendBeacon cannot
set headers), /widget/broadcasting/auth (non-mutating handshake), the
federation surfaces /sso/saml/* and /scim/v2/* (ATH-260 — the callers
are a customer's IdP and its SCIM connector, neither of which is an Cairn
client; for the SAML ACS a replayed success is precisely what the
consumed-assertion store must refuse, and SCIM's own idempotency is the
RFC 7644 §3.3 userName uniqueness 409), and the
token-minting /auth/token, /widget/session + /oauth/token (responses
carry credentials and are never replay-cached). /oauth/token has a second
reason: an authorization code is single-use and a refresh token rotates, so
replaying a cached 200 for a repeated request would hand back a credential
the reuse-detection path exists to revoke. Multipart upload endpoints accept
the header but implement chunk-level idempotency instead of response replay.cursor, limit, response meta.next_cursor).This document describes ONE /v1 namespace served by two kinds of
credential, and they are not interchangeable:
PublicApi IN ADDITION to their resource tag, they are
authenticated by a workspace API key, and each one names the
ability the key must carry. A key without that ability gets 403.The PublicApi tag is the only marker; a path prefix is NOT one
(both kinds live under /v1). Server-side the boundary is the
public-api:<ability> middleware, and a route-enumeration test
asserts the two agree in both directions — a public operation with
no such route fails the build, and a route on the public surface
that this spec does not tag PublicApi fails it too.
Abilities are <resource>:<read|write>, derived from the RBAC
permission catalog (ApiAbilities). write does not imply read.
A key may hold * for the whole surface.
/v1 is a stable major version. Within it we make only ADDITIVE
changes: new endpoints, new OPTIONAL request fields, new response
fields, and new members of a response enum.
In exchange, clients MUST ignore response fields they do not recognise and MUST tolerate unseen enum values. A client that rejects unknown fields is not compatible with this promise.
Anything else — removing or renaming a field, making an optional
field required, removing an enum member, changing the status code
for an existing condition, or changing what an existing field means
— ships as /v2, and /v1 keeps working.
Retirement: an operation being withdrawn is marked deprecated: true
here AND returns RFC 8594 Deprecation: and Sunset: headers, with
at least 180 days between the two dates. A whole major version gets
at least 365 days' notice. Every public response carries
Cairn-Api-Version: v1.
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"
}
}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"
}
]
}
}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": "article.feedback_submitted",
- "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": 1,
- "y": 1,
- "w": 1,
- "h": 1
}, - "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"
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string"
}
}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"
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| email required | string <email> |
| password required | string <password> |
| device_name required | string <= 100 characters |
| totp_code | string Required when 2FA is enabled |
{- "email": "user@example.com",
- "password": "pa$$word",
- "device_name": "string",
- "totp_code": "string"
}{- "token": "string"
}When the session is an impersonation (ATH-034), impersonation is present — clients MUST render the banner from it.
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "user@example.com",
- "two_factor_enabled": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "impersonation": {
- "impersonated_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "impersonator_name": "string",
- "expires_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-252 — requires integrations.manage. These are the apps the workspace OWNS and publishes, not the apps installed into it (that is /oauth-installs). The client secret existed once, in the create response, and is not recoverable from here or from anywhere else.
| 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",
- "description": "string",
- "client_id": "string",
- "requested_abilities": [
- "string"
], - "client_secret_prefix": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-252 — requires integrations.manage. redirect_uris are matched EXACTLY at authorize time: no prefix matching, no wildcards, no trailing-slash forgiveness. A near miss is a refusal, because prefix matching on a redirect URI is how an open redirect turns into a stolen authorization code.
requested_abilities is the app's CEILING, drawn from the ATH-250 vocabulary (ApiAbilities::all()); unknown abilities are rejected 422 at registration rather than silently granting nothing later. No consent may exceed this set, and no consent may exceed the granting user's own permissions either.
| 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 <= 120 characters |
| description | string or null <= 500 characters |
| redirect_uris required | Array of strings <uri> non-empty [ items <uri > ] Absolute https URIs (http is accepted only for localhost, for local development). Matched byte for byte at authorize time. |
| requested_abilities required | Array of strings non-empty |
{- "name": "string",
- "description": "string",
- "requested_abilities": [
- "string"
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "client_id": "string",
- "requested_abilities": [
- "string"
], - "client_secret_prefix": "string",
- "created_at": "2019-08-24T14:15:22Z"
}, - "client_secret": "string"
}ATH-252 — requires integrations.manage, and only the OWNING workspace may call it. Withdrawing an app revokes every install of it in every workspace and every token those installs hold, immediately: a developer shutting down an integration should not leave live credentials pointed at their customers.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| appId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-252 — requires integrations.manage. The other side of /oauth-apps: what this workspace has granted to somebody else's app, in the ability vocabulary the consent screen used, so "what can this integration actually do" has an answer that does not require reading code.
| 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",
- "app": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "client_id": "string",
- "requested_abilities": [
- "string"
], - "client_secret_prefix": "string",
- "created_at": "2019-08-24T14:15:22Z"
}, - "granted_abilities": [
- "string"
], - "granted_permissions": [
- "string"
], - "granted_by_user_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "granted_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
]
}ATH-252 — requires integrations.manage. Sets revoked_at on the install AND on every access token it issued, so access ends immediately rather than at the next token expiry. The rows are kept rather than deleted for the same reason a revoked API key's row is kept: last_used_at is what an incident review reads.
Idempotent — revoking an already-revoked install returns the same 200 with the original timestamp, because the caller's intent is already satisfied and a 409 would only encourage retry loops in the one situation where speed matters.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| installId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "app": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "client_id": "string",
- "requested_abilities": [
- "string"
], - "client_secret_prefix": "string",
- "created_at": "2019-08-24T14:15:22Z"
}, - "granted_abilities": [
- "string"
], - "granted_permissions": [
- "string"
], - "granted_by_user_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "granted_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}Authenticated (session or API key); any member of the workspace may call it.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-018 — authenticated; the caller becomes the new workspace's owner. Pre-context (no workspace to authorize against yet). Idempotency-Key replays the same workspace for 24h.
| Idempotency-Key | string <= 255 characters |
| name required | string [ 2 .. 80 ] characters |
{- "name": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-018 — any member of the workspace; membership is enforced by workspace.context before the handler runs.
| 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",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-018 — requires settings.manage. settings MERGES rather than replaces, so a partial update never clobbers keys it did not send.
PA-270 — that merge is DEEP, at every level. Objects are merged key by key: {"voice":{"recording_enabled":false}} changes that one key and leaves voice.recording_announcement and voice.greeting exactly as they were. (Until PA-270 it merged only the top level, so the same request deleted the rest of the voice object silently.)
ARRAYS ARE REPLACED WHOLESALE, never merged element by element — that is what lets a list SHRINK. Send the list you want stored; send [] to empty it.
AN EMPTY OBJECT NAMES NO KEYS, so {"voice":{}} changes nothing under voice (and {"settings":{}} changes nothing at all).
TO REMOVE A SETTING, SEND null. The key is stored as null and every consumer reads a stored null exactly as it reads an absent key — the platform default applies. {"voice":{"greeting":null}} drops the custom greeting and keeps its siblings; {"voice":null} retires the whole area.
PA-266 — settings.automation.sensitive_keywords is the workspace's own addition to the sensitive-page rail (Doc 07 §2.2, "no execution on payment/password pages"). Each entry is a substring matched case-insensitively against the host, path, query and fragment of any URL the "do it for me" engine is asked to act on; a match refuses the run. Use it to name the money and credential pages of your own product that the built-in English list misses — copay, nomina, paye, wire-transfer.
This list can only ever WIDEN what is refused. The built-in keywords (checkout, payment, billing, login, password, …) are always enforced on top of it and cannot be removed, overridden or allow-listed by any value stored here.
Stored canonically: entries are lowercased, trimmed and de-duplicated on write. Rejected with 422: a non-array, a non-string entry, an entry shorter than 2 or longer than 64 characters (a 1-character keyword would match nearly every URL and silently disable the engine), or more than 50 entries.
PA-268 — settings.tools.store_action_payloads decides whether the tool audit (agent_actions, Doc 05 §5) keeps the ARGUMENTS a tool was called with and the RESULT it returned. Default true: every existing workspace keeps the behaviour it has today. Set it to false and those two payloads are never written — the invocation record itself (who asked, which tool, what the permission engine decided, who approved, how it ended, how long it took, when) is written in full either way, and the row is stamped payload_scrub_reason: workspace_opt_out so a reader can tell "this workspace does not store payloads" from "the tool returned nothing".
It covers BOTH arguments and result, deliberately: an argument carries the same customer data a result does (the address in a create-order call), and it is the same pair the retention sweep drops. A control that kept one of them would be a privacy switch with a hole in it.
Rejected with 422: anything that is not a boolean — "true", "off", 7, an object. true/false, 1/0 and "1"/"0" are accepted and STORED AS A REAL BOOLEAN, because the reader compares strictly (=== false) and a stored "0" would otherwise be a setting you can save and cannot trust. Send null to retire the key back to the default.
PA-279 — settings.capture.strict_originals makes newly uploaded screenshot originals application-encrypted before object storage and queues encryption of existing originals. Enabling requires the Enterprise enforced-redaction entitlement and the default-off feature.capture.strict_originals rollout flag. Once enabled, a later flag rollback or plan downgrade does not silently weaken the stored privacy choice; send false explicitly to stop encrypting future originals. Owner/admin only through this route's existing settings.manage gate.
| workspaceId 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 |
object |
{- "name": "string",
- "settings": {
- "automation": {
- "sensitive_keywords": [
- "copay",
- "wire-transfer"
]
}, - "tools": {
- "store_action_payloads": true
}, - "capture": {
- "strict_originals": false
}
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}Steps complete when their domain event fires for the workspace (Doc 01 §7, ATH-052).
| 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": {
- "steps": [
- {
- "key": "string",
- "title": "string",
- "description": "string",
- "cta": "string",
- "completed": true
}
], - "completed_count": 0,
- "total": 0,
- "dismissed": true
}
}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 |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "steps": [
- {
- "key": "string",
- "title": "string",
- "description": "string",
- "cta": "string",
- "completed": true
}
], - "completed_count": 0,
- "total": 0,
- "dismissed": true
}
}ATH-083 — logo, colors, marker style, font; applied at render/export time (Doc 03 §2).
| 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",
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "logo_media_id": "string",
- "is_default": true
}
]
}Setting is_default clears the previous default (ATH-083).
| 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 | string <= 120 characters |
| primary_color | string^#[0-9a-fA-F]{6}$ |
| secondary_color | string^#[0-9a-fA-F]{6}$ |
| marker_style | string Enum: "ring" "dot" "badge" |
| font | string or null <= 120 characters |
| logo_media_id | string or null |
| is_default | boolean |
{- "name": "string",
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "logo_media_id": "string",
- "is_default": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "logo_media_id": "string",
- "is_default": true
}
}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 |
| brandProfileId 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 <= 120 characters |
| primary_color | string^#[0-9a-fA-F]{6}$ |
| secondary_color | string^#[0-9a-fA-F]{6}$ |
| marker_style | string Enum: "ring" "dot" "badge" |
| font | string or null <= 120 characters |
| logo_media_id | string or null |
| is_default | boolean |
{- "name": "string",
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "logo_media_id": "string",
- "is_default": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "logo_media_id": "string",
- "is_default": true
}
}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 |
| brandProfileId 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": { }
}
}Requires members.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 |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "user": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "user@example.com",
- "two_factor_enabled": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "owner",
- "permissions": { },
- "joined_at": "2019-08-24T14:15:22Z"
}
]
}Requires members.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 |
| memberId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| role | string (Role) Enum: "owner" "admin" "content_manager" "creator" "support_agent" "analyst" "viewer" |
object Per-member grants/revokes on top of the role bundle |
{- "role": "owner",
- "permissions": {
- "grant": [
- "string"
], - "revoke": [
- "string"
]
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "user": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "user@example.com",
- "two_factor_enabled": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "owner",
- "permissions": { },
- "joined_at": "2019-08-24T14:15:22Z"
}
}Requires members.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 |
| memberId 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": { }
}
}Requires members.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 |
| email required | string <email> |
| role required | string (Role) Enum: "owner" "admin" "content_manager" "creator" "support_agent" "analyst" "viewer" |
{- "email": "user@example.com",
- "role": "owner"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "role": "owner",
- "expires_at": "2019-08-24T14:15:22Z"
}
}Public — the token IS the credential. The join screen receives it from the emailed action URL, removes it from browser history after hydration, and posts it in this request body. This API endpoint never accepts the token in its URL. Lets the join screen show workspace + role before the invitee registers or logs in.
| Idempotency-Key | string <= 255 characters |
| token required | string |
{- "token": "string"
}{- "data": {
- "workspace_name": "string",
- "email": "user@example.com",
- "role": "owner",
- "expires_at": "2019-08-24T14:15:22Z"
}
}Caller must be authenticated and their email must match the invitee email. Idempotent per token — replays after success return 410.
| Idempotency-Key | string <= 255 characters |
| token required | string |
{- "token": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "user": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "user@example.com",
- "two_factor_enabled": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "owner",
- "permissions": { },
- "joined_at": "2019-08-24T14:15:22Z"
}
}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-041 — the billing entity is the organization (Cashier customer); this reads plan, trial, and subscription status. ATH-042 adds the dunning/grace state: payment_failed means an invoice failed and Stripe is retrying, dunning_grace_ends_at is when access lapses if it never lands. Note this is NOT on_grace_period, which is Cashier's post-cancellation window.
| 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": {
- "plan_id": "string",
- "on_trial": true,
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "customer_linked": true,
- "subscribed": true,
- "status": "string",
- "quantity": 0,
- "on_grace_period": true,
- "ends_at": "2019-08-24T14:15:22Z",
- "payment_failed": true,
- "dunning_since": "2019-08-24T14:15:22Z",
- "dunning_grace_ends_at": "2019-08-24T14:15:22Z"
}
}ATH-046 — invoices come from Stripe, and so do the documents: hosted_url and pdf are Stripe-hosted links (Doc 01 §6.2 "Stripe-hosted invoice PDFs"). We never render an invoice ourselves. Returns an empty list — not an error — when the org has no Stripe customer yet, so the billing page renders on a fresh install.
| 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": "string",
- "number": "string",
- "total_cents": 0,
- "currency": "string",
- "status": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "hosted_url": "string",
- "pdf": "string"
}
]
}ATH-046 — payment methods and tax IDs are handled by Stripe's hosted billing portal, never by us. Card data must never touch this application: no card form, no PAN in a request body, no PCI surface. Returns the URL to redirect the owner to.
| 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 |
{
}ATH-045 — the one-time packs a workspace can buy. purchasable is false until the founder provisions that pack's Stripe price; the UI should hide rather than offer a checkout that would 503.
| 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": "string",
- "price_cents": 0,
- "credit_cents": 0,
- "purchasable": true
}
]
}ATH-045 — returns a hosted Stripe Checkout URL (one-time payment, not a subscription). Credit is granted by the checkout.session.completed webhook once the session is actually PAID, never here — this endpoint only opens the till.
| 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 |
| pack required | string |
{- "pack": "string"
}{
}ATH-045 — threshold-triggered auto-recharge against the org's saved default payment method. card_on_file is false when there is nothing to charge off-session, in which case enabling this will silently never fire — surface that in the UI.
| 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": {
- "enabled": true,
- "threshold_cents": 0,
- "pack": "string",
- "card_on_file": true,
- "cooldown_minutes": 0,
- "last_recharged_at": "2019-08-24T14:15:22Z"
}
}ATH-045 — enabling requires both a threshold and a pack: a half-configured auto-recharge that silently never fires is worse than one that is plainly off.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| enabled required | boolean |
| threshold_cents | integer or null [ 0 .. 100000 ] |
| pack | string or null |
{- "enabled": true,
- "threshold_cents": 100000,
- "pack": "string"
}{- "data": {
- "enabled": true,
- "threshold_cents": 0,
- "pack": "string"
}
}ATH-047 — per-limit usage against the plan's ceiling. exceeded is the read-only state a downgrade can produce: the data stays (nothing is ever deleted), but creation is blocked until the workspace is back under the ceiling or upgrades. limit: null means uncapped.
| 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": {
- "property1": {
- "usage": 0,
- "limit": 0,
- "remaining": 0,
- "exceeded": true
}, - "property2": {
- "usage": 0,
- "limit": 0,
- "remaining": 0,
- "exceeded": true
}
}
}ATH-043 — seats are per workspace: allowance = the plan's included seats + any extra seats purchased on the Stripe subscription. occupied counts members plus pending invitations, so a seat is held from the moment it is offered. allowance: null means uncapped (Enterprise custom terms).
| 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": {
- "included": 0,
- "purchased": 0,
- "allowance": 0,
- "occupied": 0,
- "available": 0,
- "supports_extra_seats": true,
- "extra_seat_price_cents": 0
}
}ATH-043 — sets the extra-seat subscription item's quantity on Stripe, prorated. quantity is the ABSOLUTE number of extra seats wanted (not a delta), so a retried request is idempotent. Only plans with an extra-seat price support this (Scale); other plans get 422 and should upgrade instead. Cannot be set below the seats currently occupied beyond the included allowance.
| 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 |
| quantity required | integer [ 0 .. 500 ] |
{- "quantity": 500
}{- "data": {
- "purchased": 0,
- "allowance": 0,
- "occupied": 0
}
}Requires billing.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 |
| plan required | string Enum: "starter" "growth" "scale" |
{- "plan": "starter"
}{
}ATH-044 — the current AI-credit balance (from the append-only ledger), whether the plan is unlimited, and recent grants/debits for the billing UI and top-up upsell.
| 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": {
- "unlimited": true,
- "balance_micros": 0,
- "balance_cents": 0,
- "entries": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "delta_cents": 0,
- "balance_cents": 0,
- "reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Signature-verified + idempotently deduped. Handles the billing lifecycle: invoice.paid (monthly AI-credit grant + clears dunning), invoice.payment_failed (dunning/grace), the customer.subscription.* mirror, and checkout.session.completed / async_payment_succeeded / async_payment_failed for one-time credit top-ups (ATH-016 — async methods settle after checkout). Not a client API.
Org-enforced redaction and domain rules (Doc 02 §3.1, §7, §8). Enforced entries cannot be overridden client-side. ATH-235 adds enforced_rules + policy_checksum (workspace redaction policy — Enterprise; empty/null below the plan).
| 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": {
- "blocked_domains": [
- "string"
], - "enforce_masking": true,
- "enforced_rules": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true,
- "created_at": "2019-08-24T14:15:22Z"
}
], - "policy_checksum": "string"
}
}ATH-235 — Doc 02 §7.3–7.4. Requires settings.manage. Readable on any plan so a downgraded workspace can still audit (and delete) its rules; ingest and the capture-policy fetch stop applying them the moment the entitlement lapses.
| 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",
- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true,
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-235 — Doc 02 §7.3–7.4. Requires settings.manage and the Enterprise feature.security.enforced_redaction entitlement (422 below it, ATH-094 pattern). New rules apply to captures ingested from this point on; existing guides are not re-scanned.
| 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 |
| kind required | string (RedactionRuleKind) Enum: "category" "selector" "field_label" category = auto-blur a Smart Blur detector category wherever the Doc 02 §7.2 bank finds it (placement lands with ATH-234); selector = blur the bounding box of any captured element whose recorded selector_candidates contain the pattern; field_label = blur any captured element whose accessible name/label matches the pattern as a case-insensitive regex — "blur any field labeled Patient" (Doc 02 §7.4). |
| pattern | string or null <= 500 characters Required for selector/field_label kinds; must compile as a regex for field_label |
RedactionCategory (string) or null Required for the category kind | |
| enforced | boolean Default: true |
{- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true,
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-235 — partial update; kind/pattern/category are re-validated together. Requires settings.manage + the Enterprise entitlement (422 below it). Already-applied regions keep their captured shape; the change affects future ingests.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ruleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| kind | string (RedactionRuleKind) Enum: "category" "selector" "field_label" category = auto-blur a Smart Blur detector category wherever the Doc 02 §7.2 bank finds it (placement lands with ATH-234); selector = blur the bounding box of any captured element whose recorded selector_candidates contain the pattern; field_label = blur any captured element whose accessible name/label matches the pattern as a case-insensitive regex — "blur any field labeled Patient" (Doc 02 §7.4). |
| pattern | string or null <= 500 characters |
RedactionCategory (string) or null | |
| enforced | boolean |
{- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "category",
- "pattern": "string",
- "category": "pii.email",
- "enforced": true,
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-235 — allowed on any plan (cleanup after downgrade). Regions the rule already burned into guides remain: policy redactions are evidence, not reversible state.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ruleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Requires guides.create. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| Idempotency-Key | string <= 255 characters |
| source required | string Enum: "extension" "desktop" "upload" |
object (AppContext) | |
Ulid (string) or null ATH-282 (Doc 07 §1 disposition "b") — the capture request this recording answers, when the session was opened from a capture-request deep link. Must reference an open or in-progress request in the SAME workspace; anything else is 422, which is what stops a deep link from tagging a recording onto another tenant's work order. Accepting it moves that request to |
{- "source": "extension",
- "capture_request_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "source": "extension",
- "status": "recording",
- "started_at": "2019-08-24T14:15:22Z"
}
}Requires guides.create. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| captureId 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 |
required | Array of objects (CaptureStep) <= 50 items |
{- "steps": [
- {
- "seq": 1,
- "type": "click",
- "ts": 0,
- "screenshot_ref": "string",
- "click": {
- "x": 0,
- "y": 0
}, - "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"
}, - "input": {
- "masked": true,
- "label": "string"
}, - "voice_transcript_span": [
- 0,
- 0
]
}
]
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-047 — a workspace at its plan's guide ceiling gets 402 plan_limit_reached here, at the API boundary, rather than losing the capture inside the assembly job. Re-finalizing a session whose guide already exists always succeeds: the limit blocks new guides, and a replay creates none.
| captureId 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": {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "assembling"
}
}PA-184 (Doc 02 §5) — an authoring member mints a short-lived, signed link a phone opens to add a screenshot to THIS capture session. Requires guides.create, scoped to the header-resolved workspace: a session in another workspace is 404, never another tenant's rows. The link is a Laravel signed URL (HMAC over the session id + expiry), so no token is stored and the id is useless without the matching signature — encode url as a QR for the phone. 404 below feature.capture.mobile_upload; 422 once the session is no longer recording.
| captureId 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 |
{
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
| status | string Enum: "draft" "in_review" "published" "archived" |
| folder_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: folder_id=01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "meta": {
- "next_cursor": "string"
}, - "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"
}
]
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| 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": 1,
- "y": 1,
- "w": 1,
- "h": 1
}, - "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"
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string"
}
}ATH-080 — typed editor operations applied in order and appended to the guide's operation log (undo + version snapshots, Doc 03 §2). The op vocabulary grows with the editor tickets (ATH-081+).
| guideId 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 |
required | Array of objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects or objects (GuideOp) [ 1 .. 50 ] items |
{- "ops": [
- {
- "type": "guide.update",
- "payload": {
- "title": "string",
- "description": "string",
- "brand_profile_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}
]
}{- "data": {
- "applied": 0,
- "last_seq": 0
}
}PA-277 — records a durable author decision for assisted Smart Blur regions on one screenshot. Accepted regions are applied to the derived image; dismissed regions stay excluded. Decisions survive detector re-runs. Requires guides.edit_any and feature.capture.smart_blur.
| mediaId 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 |
| region_ids required | Array of strings (Ulid) [ 1 .. 100 ] items unique [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ] |
| decision required | string Enum: "accept" "dismiss" |
{- "region_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "decision": "accept"
}{- "data": {
- "updated": 0,
- "decision": "accept",
- "regions": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "rect": {
- "x": 1,
- "y": 1,
- "w": 1,
- "h": 1
}, - "category": "pii.email",
- "status": "pending_review"
}
]
}
}ATH-085 — rewrite step / adjust tone / regenerate title+description / summarize (Doc 03 §2). Nothing persists here: the editor shows the suggestion and applies it via applyGuideOps, so every AI-assisted change is a logged, undoable operation.
| guideId 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 |
| action required | string Enum: "rewrite_step" "adjust_tone" "regenerate_meta" "summarize" |
| step_id | string Required for rewrite_step / adjust_tone |
| tone | string Enum: "friendly" "formal" "concise" "enthusiastic" Required for adjust_tone |
{- "action": "rewrite_step",
- "step_id": "string",
- "tone": "friendly"
}{- "data": {
- "suggestion": { }
}
}ATH-086 — rows are never deleted; retention (Doc 03 §2.1) is a query-time window per plan, so an upgrade restores older history.
| 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": [
- {
- "number": 0,
- "label": "string",
- "created_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "step_count": 0
}
]
}Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| number required | integer |
{- "data": {
- "added": [
- { }
], - "removed": [
- { }
], - "changed": [
- {
- "id": "string",
- "before": { },
- "after": { }
}
], - "meta_changed": true
}
}ATH-084 — returns a media id immediately; a queued job renders the frames (edited > annotated > raw screenshot variants). Poll getGuideGif until ready.
| guideId 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 |
| from_seq required | integer >= 1 |
| to_seq required | integer >= 1 |
{- "from_seq": 1,
- "to_seq": 1
}{- "data": {
- "media_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| mediaId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{
}ATH-087 — one editor per guide (Doc 03 §2.1). Locks go stale after 90s without a heartbeat; takeover is always available. 409 carries the current holder.
| guideId 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 |
| force | boolean |
{- "force": true
}{- "data": {
- "acquired": true,
- "locked_by": "string",
- "locked_by_name": "string",
- "heartbeat_at": "2019-08-24T14:15:22Z"
}
}Requires guides.edit_any. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId 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": { }
}
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| 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",
- "step_id": "string",
- "body": "string",
- "author_id": "string",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId 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 |
| body required | string <= 5000 characters |
| step_id | string or null |
{- "body": "string",
- "step_id": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "step_id": "string",
- "body": "string",
- "author_id": "string",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| commentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| resolved required | boolean |
{- "resolved": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "step_id": "string",
- "body": "string",
- "author_id": "string",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires guides.view. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| guideId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| commentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-082 — sha256 content-addressed like capture media; a re-upload dedupes to the existing asset. Referenced by step.replace_screenshot.
| guideId 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 |
| file required | string <binary> |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "screenshot",
- "width": 0,
- "height": 0,
- "bytes": 0,
- "sha256": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}PA-279 (Doc 02 §7.1) — requires settings.security, a workspace
with settings.capture.strict_originals = true, and a screenshot
that already has an applied redaction rendered into its edited
derivative. The operation permanently deletes the encrypted raw
object and every unredacted derivative (annotated + thumbnails),
retaining only the redacted edited image. It is intentionally
irreversible: future crop/blur or audience-profile changes cannot
be re-rendered after deletion.
Idempotent: deleting an already-deleted original returns the same
state and original timestamp. A foreign-tenant media id is 404.
404 below the default-off feature.capture.strict_originals flag
unless this workspace already enabled strict mode (a rollout
rollback never strands an existing privacy control). 409 means no
applied redacted derivative exists yet; apply a blur and wait for
rendering before retrying. 422 means the media is not a screenshot
or strict mode is not enabled for this workspace.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| mediaId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "media_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "encrypted": true,
- "deleted_at": "2019-08-24T14:15:22Z",
- "deleted_by": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}Multipart upload, retry/resume-safe via content addressing: send the client-computed sha256 and a re-upload of the same bytes returns the existing asset instead of storing a duplicate. Chunked/tus-style resumable transfer is deferred until the desktop app needs it (Doc 08 §7 open decisions) — extension screenshots are small.
| captureId 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 |
| kind required | string Enum: "screenshot" "audio" |
| file required | string <binary> |
| sha256 | string Client-computed content hash (hex); enables idempotent retries |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "screenshot",
- "width": 0,
- "height": 0,
- "bytes": 0,
- "sha256": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}PA-184 (Doc 02 §5) — the phone's upload endpoint. NOT member-authed:
the signed URL minted by createCaptureMobileUploadLink IS the
credential (security: []). Laravel's signed middleware validates
the HMAC + expiry before the controller runs, so a tampered id, an
expired link, or a signature minted for another session/tenant is
refused (403) — an upload can only ever land in the token's own
capture session and workspace. The image flows through the SAME
content-addressed media pipeline as uploadCaptureMedia (client
sha256 → dedup) and is attached to the session as a screenshot
step. Only images are accepted, mime + size enforced (422). 404
below feature.capture.mobile_upload; 409 once the session is no
longer recording.
| captureId 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 |
| file required | string <binary> |
| sha256 | string Client-computed content hash (hex); enables idempotent retries |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "screenshot",
- "width": 0,
- "height": 0,
- "bytes": 0,
- "sha256": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-102 — provider cost (llm_calls.cost_micros) vs credit revenue (agent debits), globally + by task + top workspaces.
{- "data": {
- "cost_micros": 0,
- "revenue_micros": 0,
- "margin_micros": 0,
- "margin_pct": 0,
- "calls": 0,
- "by_task": [
- {
- "task": "string",
- "calls": 0,
- "cost_micros": 0,
- "tokens": 0
}
], - "top_workspaces": [
- {
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cost_micros": 0
}
]
}
}Matches workspace name/slug substrings or an exact ULID. Includes suspended and soft-deleted tenants.
| q | string |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z",
- "suspended_at": "2019-08-24T14:15:22Z",
- "deleted_at": "2019-08-24T14:15:22Z",
- "members_count": 0,
- "ai_credit_balance_cents": 0
}
]
}Sets plan_id directly (comps, sales overrides). Purges the tenant's plan-derived feature-flag cache — ad-hoc flag targeting on those flags must be re-applied afterwards.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| plan required | string Enum: "free" "starter" "growth" "scale" "enterprise" |
{- "plan": "free"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z",
- "suspended_at": "2019-08-24T14:15:22Z",
- "deleted_at": "2019-08-24T14:15:22Z",
- "members_count": 0,
- "ai_credit_balance_cents": 0
}
}Members lose access immediately (context resolution 404s); data is retained for recovery.
| 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": { }
}
}Members are blocked (403) at workspace-context resolution until unsuspended.
| 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",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z",
- "suspended_at": "2019-08-24T14:15:22Z",
- "deleted_at": "2019-08-24T14:15:22Z",
- "members_count": 0,
- "ai_credit_balance_cents": 0
}
}Super-admin only (platform.admin); a workspace role can never grant it.
| 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",
- "organization_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "plan": "free",
- "trial_ends_at": "2019-08-24T14:15:22Z",
- "settings": { },
- "created_at": "2019-08-24T14:15:22Z",
- "suspended_at": "2019-08-24T14:15:22Z",
- "deleted_at": "2019-08-24T14:15:22Z",
- "members_count": 0,
- "ai_credit_balance_cents": 0
}
}Adds a positive, append-only credit-ledger entry. The required Idempotency-Key is also used as the ledger reference, so retries cannot grant credits twice even after the HTTP replay cache expires.
| 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 |
| amount_cents required | integer [ 1 .. 100000000 ] |
| note | string or null <= 500 characters |
{- "amount_cents": 1,
- "note": "string"
}{- "data": {
- "applied": true,
- "balance_cents": 0,
- "reference": "string"
}
}Super-admin only (platform.admin); a workspace role can never grant it.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| feature required | string A defined Pennant flag name (feature.. |
| active required | boolean |
{- "active": true
}{- "data": {
- "feature": "string",
- "active": true
}
}Super-admin only (platform.admin); a workspace role can never grant it.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| feature required | string A defined Pennant flag name (feature.. |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Issues a short-lived token that authenticates AS the target user (ATH-034). Starting a new impersonation revokes any previous one for the same user. Actions taken with the token are audit-logged as actor_type=super_admin with the target recorded in metadata. Super admins cannot be impersonated.
| userId 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": {
- "token": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "user": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "user@example.com",
- "two_factor_enabled": true,
- "created_at": "2019-08-24T14:15:22Z"
}
}
}Super-admin only (platform.admin); a workspace role can never grant it.
| userId 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-137 — name/email substring search, most-recently-seen first, capped at 50.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| q | string <= 100 characters |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "verified": true,
- "attributes": {
- "property1": "string",
- "property2": "string"
}, - "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "suppression_reason": "string"
}
]
}Requires contacts.view. 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 |
| 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",
- "verified": true,
- "attributes": {
- "property1": "string",
- "property2": "string"
}, - "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "suppression_reason": "string",
- "conversations": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "channel": "string",
- "state": "string",
- "rating": 0,
- "last_message_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires contacts.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 |
| 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 <= 255 characters |
| phone | string or null <= 32 characters |
object <= 30 properties Freeform scalar custom attributes. |
{- "name": "string",
- "phone": "string",
- "attributes": {
- "property1": "string",
- "property2": "string"
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "verified": true,
- "attributes": {
- "property1": "string",
- "property2": "string"
}, - "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "suppression_reason": "string"
}
}ATH-137 — header row required, email column mandatory; name/phone map to profile fields, other columns become custom attributes. Existing contacts (matched by email) update — blanks never clobber, attributes merge. Invalid emails are skipped and counted. Throttled 10/min.
ATH-047 — if the workspace hits its plan's contact ceiling mid-file, the rows that fit still import and the rest come back as limit_skipped rather than failing the whole upload. Existing contacts keep updating even when over the limit.
| 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 |
| csv required | string <= 1000000 characters |
{- "csv": "string"
}{- "data": {
- "created": 0,
- "updated": 0,
- "skipped": 0,
- "limit_skipped": 0
}
}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"
}
}Saved contact segments (Doc 04 §6, ATH-210): a boolean filter tree over contact attributes, activity, and channel facts. Membership is materialized (nightly recount + incremental per-contact refresh); dynamic previews evaluate live.
ATH-210 — alphabetical. contact_count and refreshed_at come from the materialized snapshot (nightly recount + incremental per-contact refresh), not a live query.
| 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",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}, - "contact_count": 0,
- "refreshed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-210 — definition is validated as a filter AST (kind-by-kind; invalid trees 422 with node paths). Membership materializes via a queued job, so contact_count reads 0 until it lands — use the preview endpoint for an immediate count. Names are unique per workspace. Throttled 30/min.
| 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 <= 120 characters |
required | object (SegmentFilterNode) ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on |
{- "name": "string",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}, - "contact_count": 0,
- "refreshed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-210 — evaluates definition against the workspace's contacts NOW (no persistence): total match count plus a most-recently-seen sample of up to 10. Invalid trees 422 with node paths. Throttled 30/min — it runs real queries.
| 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 |
required | object (SegmentFilterNode) ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on |
{- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}
}{- "data": {
- "count": 0,
- "sample": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "user@example.com",
- "name": "string",
- "phone": "string",
- "verified": true,
- "attributes": {
- "property1": "string",
- "property2": "string"
}, - "last_seen_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "suppression_reason": "string"
}
]
}
}Requires contacts.view. 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 |
| segmentId 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",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}, - "contact_count": 0,
- "refreshed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-210 — a changed definition re-validates the AST and queues a full re-materialization; contact_count reflects the OLD filter until the job lands.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| segmentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string <= 120 characters |
object (SegmentFilterNode) ATH-210 — one node of a segment's boolean filter tree (Doc 04 §6). Which other properties apply depends on |
{- "name": "string",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "definition": {
- "kind": "and",
- "children": [
- { }
], - "child": { },
- "field": "email",
- "key": "string",
- "op": "eq",
- "value": "string",
- "fact": "has_email",
- "days": 1,
- "event": "string",
- "min_count": 1,
- "within_days": 1,
- "match": "string"
}, - "contact_count": 0,
- "refreshed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires contacts.manage. Membership rows cascade and member contacts' cached segment lists are rewritten. Scoped to the workspace in the path.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| segmentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}One-shot email campaigns to a segment (Doc 04 §6, ATH-211). The audience is a Segment's MATERIALIZED membership, never a live filter run. Sending fans out one queued send per contact through the ordinary email channel pipeline, and a per-recipient ledger row (unique per campaign+contact, claimed before the send) is what makes a redelivered job a no-op — a campaign never double-sends. Suppressed contacts (unsubscribed or hard-bounced) are excluded at fan-out AND re-checked at send time.
ATH-211 — cursor-paginated, newest first. stats are the campaign's own counters, incremented as the fan-out runs, not a live aggregate over the recipient ledger.
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "draft" "scheduled" "sending" "sent" "paused" |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "stats": {
- "recipients": 0,
- "sent": 0,
- "skipped": 0,
- "failed": 0,
- "bounced": 0,
- "unsubscribed": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-211 — created in draft; nothing sends until the send endpoint is called. Requires the feature.messaging.campaigns plan entitlement (Growth and above) — without it every write 422s. segment_id must name a segment in the same workspace. Names are unique per workspace.
ATH-212 — channel: in_app additionally requires the feature.messaging.in_app_campaigns flag.
| 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 <= 120 characters |
| channel | string Default: "email" Enum: "email" "in_app" ATH-212 — how the campaign reaches its audience.
|
| subject required | string <= 255 characters |
| body required | string <= 20000 characters Plain-text body with merge fields. Syntax is deliberately small: |
| segment_id | string or null The audience. Its MATERIALIZED membership is the recipient list, so a segment whose refresh has not landed yet sends to what the snapshot currently holds. Required before sending. |
| scheduled_at | string or null <date-time> Set via the send endpoint; read-only here except to clear it. |
{- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "scheduled_at": "2019-08-24T14:15:22Z"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "stats": {
- "recipients": 0,
- "sent": 0,
- "skipped": 0,
- "failed": 0,
- "bounced": 0,
- "unsubscribed": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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 |
| campaignId 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",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "stats": {
- "recipients": 0,
- "sent": 0,
- "skipped": 0,
- "failed": 0,
- "bounced": 0,
- "unsubscribed": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-211 — content and audience are editable only while the campaign is draft or scheduled; editing one that is sending or sent 422s, because recipients have already been claimed against the old content. status accepts paused (stop launching further recipients) and draft (un-schedule).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| campaignId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string <= 120 characters |
| channel | string Default: "email" Enum: "email" "in_app" ATH-212 — how the campaign reaches its audience.
|
| subject | string <= 255 characters |
| body | string <= 20000 characters Plain-text body with merge fields. Syntax is deliberately small: |
| segment_id | string or null The audience. Its MATERIALIZED membership is the recipient list, so a segment whose refresh has not landed yet sends to what the snapshot currently holds. Required before sending. |
| scheduled_at | string or null <date-time> Set via the send endpoint; read-only here except to clear it. |
| status | string Enum: "draft" "paused" |
{- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "status": "draft"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "stats": {
- "recipients": 0,
- "sent": 0,
- "skipped": 0,
- "failed": 0,
- "bounced": 0,
- "unsubscribed": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-211 — only draft and scheduled campaigns delete; one that is sending or has sent is a delivery record and 422s. The recipient ledger cascades with the row.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| campaignId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-211 — with no body (or a null scheduled_at) the campaign moves to sending and the fan-out queues immediately; with a future scheduled_at it moves to scheduled and the every-minute due-campaign sweep launches it then — never earlier. Requires feature.messaging.campaigns and a segment_id.
ATH-212 — sending an in_app campaign resolves and freezes its audience the same way, but queues no delivery work: each recipient waits, claimed but pending, until that contact next appears in the widget.
Calling send twice is safe: the transition out of draft / scheduled is a conditional update, so only one caller wins, and even a duplicated fan-out job cannot double-send because each recipient row is claimed before its send.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| campaignId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| scheduled_at | string or null <date-time> Future instant to send at; null/omitted sends now. |
{- "scheduled_at": "2019-08-24T14:15:22Z"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "channel": "email",
- "subject": "string",
- "body": "string",
- "segment_id": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "stats": {
- "recipients": 0,
- "sent": 0,
- "skipped": 0,
- "failed": 0,
- "bounced": 0,
- "unsubscribed": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}RAG ingestion (Doc 05 §3): the sources the AI agent retrieves from — crawls, uploads, and the agent's own settings. Distinct from KnowledgeBase, which is the human-facing content.
Requires ai.configure. 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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "max_pages": 0,
- "max_depth": 0,
- "pages_crawled": 0,
- "pages_skipped": 0,
- "last_crawled_at": "2019-08-24T14:15:22Z",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-152 — points the agent at a domain. The crawl runs on the queue (robots-aware, sitemap-seeded, depth/page capped) and its pages become public knowledge. Throttled 20/min.
| 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 |
| root_url required | string <uri> <= 2048 characters |
| max_pages | integer [ 1 .. 2000 ] Default: 100 |
| max_depth | integer [ 0 .. 10 ] Default: 3 |
{- "max_pages": 100,
- "max_depth": 3
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "max_pages": 0,
- "max_depth": 0,
- "pages_crawled": 0,
- "pages_skipped": 0,
- "last_crawled_at": "2019-08-24T14:15:22Z",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires ai.configure. 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 |
| crawlId 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": { }
}
}Requires ai.configure. 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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "filename": "string",
- "title": "string",
- "audience": "public",
- "size": 0,
- "status": "pending",
- "error": "string",
- "last_synced_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-153 — PDF/DOCX/MD/HTML/TXT up to 20 MB. The file is stored and an ingest job extracts, chunks, and embeds it. Throttled 60/min.
| 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 |
| file required | string <binary> |
| title | string <= 255 characters |
| audience | string Default: "public" Enum: "public" "customers" "internal" |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "filename": "string",
- "title": "string",
- "audience": "public",
- "size": 0,
- "status": "pending",
- "error": "string",
- "last_synced_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires ai.configure. 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 |
| sourceId 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-281 (Doc 07 §1) — the human-facing surface of the self-healing loop. Returns the workspace's open knowledge-gap clusters ranked by volume (data, cursor-paginated), the guides currently paused for selector drift with their step-health context (drifted_guides), and the triage summary counts (summary). Requires articles.manage; scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows. Gated on feature.knowledge.self-healing: 404 (not 403, so the flag leaks nothing) below a design-partner workspace. summary and drifted_guides accompany every page of the cluster cursor.
ATH-285 (Doc 07 §1 "ROI attribution") adds roi: deflected contacts over a trailing window, their modelled value, and the knowledge sources that earned the credit.
| workspaceId 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 .. 100 ] Default: 25 |
| roi_days | integer [ 1 .. 90 ] Default: 30 ATH-285 — trailing window (days, ending now) the |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "canonical_question": "string",
- "gap_count": 0,
- "capture": {
- "request_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "requested_at": "2019-08-24T14:15:22Z"
}, - "draft": {
- "status": "pending",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "requested_at": "2019-08-24T14:15:22Z"
}, - "est_deflection_value_cents": 0,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "disposition": "string",
- "samples": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "question": "string"
}
]
}
], - "summary": {
- "cluster_count": 0,
- "gap_count": 0,
- "drifted_guide_count": 0
}, - "drifted_guides": [
- {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "source_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "drifted_at": "2019-08-24T14:15:22Z",
- "failing_step_count": 0,
- "last_ok_at": "2019-08-24T14:15:22Z"
}
], - "roi": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "deflections_agent": 0,
- "deflections_walkthrough": 0,
- "deflections_total": 0,
- "escalations": 0,
- "deflection_rate": 0,
- "value_cents": 0,
- "value_per_contact_cents": 0,
- "by_source": [
- {
- "source_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "guide",
- "ref_id": "string",
- "title": "string",
- "deflections": 0,
- "value_cents": 0
}
]
}
}ATH-281 (Doc 07 §1 disposition "c) dismiss") — records a triage disposition on a gap cluster so it drops out of the open Knowledge Health queue. Requires articles.manage. Idempotent: replaying the same disposition is a no-op returning the same cluster. The capture-request (ATH-282) and AI-draft (ATH-283) dispositions are owned by their own tickets and are not part of this endpoint.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| clusterId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| disposition required | string Enum: "reviewed" "dismissed" reviewed = triaged and acknowledged; dismissed = intentionally ignored. Both remove the cluster from the open queue. |
{- "disposition": "reviewed"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "canonical_question": "string",
- "gap_count": 0,
- "capture": {
- "request_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "requested_at": "2019-08-24T14:15:22Z"
}, - "draft": {
- "status": "pending",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "requested_at": "2019-08-24T14:15:22Z"
}, - "est_deflection_value_cents": 0,
- "last_seen_at": "2019-08-24T14:15:22Z",
- "disposition": "string",
- "samples": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "question": "string"
}
]
}
}ATH-283 (Doc 07 §1 disposition "a) AI-draft article from existing partial knowledge") — queues a job that writes a DRAFT article for this gap-cluster theme and returns immediately. The draft is grounded in the cluster's member questions (what people actually asked) and in existing knowledge retrieved for the canonical question, so it reuses real facts and cites real sources rather than inventing them. It lands in the ordinary ATH-090 draft→review→publish workflow as status=draft for a human to edit and approve; nothing is published by this call.
Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag, matching the rest of the surface). Drafting spends AI credits: the response is 202 whether the job was newly queued or was already in flight, and a cluster that already has a pending or created draft is NEVER drafted twice — the repeat call returns the existing draft reference untouched. A workspace out of credits gets 402.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| clusterId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "status": "pending",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "requested_at": "2019-08-24T14:15:22Z"
}
}ATH-282 (Doc 07 §1 disposition "b) CAPTURE REQUEST → assignment") — the complement to AI-drafting. Some themes cannot be answered from existing knowledge because nobody has written the flow down yet; those need a human to RECORD it, not a model to paraphrase what isn't there. This creates a work order against the cluster, optionally assigned to a workspace member, and returns a deep link that opens the browser extension primed to record for it.
Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag, matching the rest of the surface). A cluster carries at most ONE live capture request: a second call while one is open or in progress returns that request with 200 instead of creating a duplicate work order, so two reviewers clicking the same row cannot assign the same recording twice. A dismissed request releases the theme, which may then be requested again.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| clusterId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
Ulid (string) or null Workspace member to assign the recording to. Must be a member of this workspace; anything else is 422. Omit to create the request unassigned and assign it later. | |
| title | string or null <= 200 characters What to record. Defaults to the cluster's canonical question — the thing people actually kept asking. |
{- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cluster_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "canonical_question": "string",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "assignee_name": "string",
- "requested_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
}ATH-282 (Doc 07 §1) — the workspace's capture work orders, newest first, cursor-paginated. Requires articles.manage and is gated on feature.knowledge.self-healing (404 below the flag).
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "open" "in_progress" "completed" "dismissed" Filter to one lifecycle state. Omit for every request in the workspace. |
| assignee_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: assignee_id=01j8me9fycv0q4t4c7wz8k2xt1 Filter to one assignee — the SME's own queue of recordings to make. |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cluster_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "canonical_question": "string",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "assignee_name": "string",
- "requested_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
]
}ATH-282 (Doc 07 §1) — reads a single work order. This is the endpoint the browser extension calls to VERIFY a capture-request deep link before it starts recording: the id arrives from a web page, which is untrusted input, so the extension resolves it against the API using the signed-in user's own token. A request belonging to another workspace is a 404 here, which is precisely what makes a forged or stale deep link inert.
Gated on guides.create rather than articles.manage: the assignee is an SME who records the flow, and requiring the content-maintenance permission would lock the deep link out for exactly the person it was sent to.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| requestId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cluster_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "canonical_question": "string",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "assignee_name": "string",
- "requested_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
}ATH-282 (Doc 07 §1) — moves a work order through its lifecycle. assignee_id (re)assigns it; status transitions it. Requires articles.manage and is gated on feature.knowledge.self-healing.
Transitions are one-way out of the terminal states: completed and dismissed requests reject further changes with 422, so a closed work order cannot be silently reopened or reassigned. Dismissing RELEASES the gap cluster, which becomes requestable again; completing links the resulting guide back to the cluster, which is how the loop closes. in_progress is normally reached by the extension starting a recording rather than by this endpoint.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| requestId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| status | string Enum: "open" "in_progress" "completed" "dismissed" |
Ulid (string) or null Workspace member to assign to; null unassigns. | |
Ulid (string) or null The recorded guide that answers this request. Required when transitioning to |
{- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cluster_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "canonical_question": "string",
- "status": "open",
- "assignee_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "assignee_name": "string",
- "requested_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
}ATH-172 — how the AI would have handled real conversations in shadow mode, with AI-draft vs human-reply pairs to review before enabling the agent live.
| 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": {
- "total": 0,
- "compared": 0,
- "by_outcome": {
- "answered": 0,
- "escalated": 0,
- "no_answer": 0
}, - "pairs": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "outcome": "string",
- "confidence": 0,
- "ai_draft": "string",
- "human_reply": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires ai.configure. 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": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "persona": "string",
- "instructions": "string",
- "status": "inactive",
- "settings": {
- "answer_length": "short",
- "blocked_topics": [
- "string"
], - "languages": [
- "string"
], - "escalate_on_low_confidence": true
}, - "channels": [
- "widget"
]
}
}ATH-164 — persona, custom instructions, the on/active/review mode, and a settings bag (answer length, blocked topics, languages, escalation rules).
PA-271 — settings MERGES on update, and here is what that means exactly, because the previous one-line claim was not the whole behaviour. A key you send replaces the stored value for that key; keys you omit are left alone, so a partial update never clobbers the rest of the bag. blocked_topics and languages are lists and are replaced WHOLESALE, never merged element-wise — send the list you want stored, and send [] to clear it. Objects, should this bag ever grow one, merge key-by-key at every depth, and null retires a key.
A key that is not in AgentSettings is REJECTED with 422 (it names the offending keys), not stored and not silently dropped. This is a deliberate behaviour change: before PA-271 such keys were discarded without any signal, so a typo'd key looked like a successful save that did nothing.
| workspaceId 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 <= 120 characters |
| persona | string or null <= 2000 characters |
| instructions | string or null <= 5000 characters |
| status | string Enum: "inactive" "active" "review" "shadow" |
object (AgentSettings) The agent's tuning bag. These are the keys AgentSettingsController validates — the read and write shapes reference this same schema, so they cannot drift apart. PA-271 — the bag is CLOSED ( | |
| channels | Array of strings Items Enum: "widget" "email" |
{- "name": "string",
- "persona": "string",
- "instructions": "string",
- "status": "inactive",
- "settings": {
- "answer_length": "short",
- "blocked_topics": [
- "string"
], - "languages": [
- "string"
], - "escalate_on_low_confidence": true
}, - "channels": [
- "widget"
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "persona": "string",
- "instructions": "string",
- "status": "inactive",
- "settings": {
- "answer_length": "short",
- "blocked_topics": [
- "string"
], - "languages": [
- "string"
], - "escalate_on_low_confidence": true
}, - "channels": [
- "widget"
]
}
}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.
Requires articles.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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "parent_id": "string",
- "access_level": "public",
- "position": 0,
- "children": [
- { }
]
}
]
}Requires articles.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 | string <= 120 characters |
| parent_id | string or null |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
| position | integer >= 0 |
{- "name": "string",
- "parent_id": "string",
- "access_level": "public",
- "position": 0
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "parent_id": "string",
- "access_level": "public",
- "position": 0,
- "children": [
- { }
]
}
}Requires articles.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 |
| collectionId 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 <= 120 characters |
| parent_id | string or null |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
| position | integer >= 0 |
{- "name": "string",
- "parent_id": "string",
- "access_level": "public",
- "position": 0
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "parent_id": "string",
- "access_level": "public",
- "position": 0,
- "children": [
- { }
]
}
}Requires articles.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 |
| collectionId 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": { }
}
}Requires articles.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 |
| collection_id | string |
| status | string Enum: "draft" "in_review" "published" "archived" |
{- "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"
}
]
}Requires articles.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 |
| title | string <= 255 characters |
| body | string or null |
| collection_id | string or null |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
| position | integer >= 0 |
{- "title": "string",
- "body": "string",
- "collection_id": "string",
- "access_level": "public",
- "position": 0
}{- "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"
}
}Requires articles.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 |
| 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"
}
}Requires articles.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 |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| title | string <= 255 characters |
| body | string or null |
| collection_id | string or null |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
| position | integer >= 0 |
{- "title": "string",
- "body": "string",
- "collection_id": "string",
- "access_level": "public",
- "position": 0
}{- "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"
}
}Requires articles.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 |
| articleId 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-090 — "creator submits, content_manager approves" (Doc 03 §4): transitioning INTO published requires the content-publish permission and stamps approved_by + published_at.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| status required | string Enum: "draft" "in_review" "published" "archived" |
{- "status": "draft"
}{- "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-242 — the locale variants of one article (Doc 03 §5 "Multilingual"). Requires articles.manage and the feature.content.multilingual flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403 and never another tenant's rows. Each variant carries its OWN publish state: a draft translation does not serve even when the source article is live.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "locale": "string",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "body": "string",
- "status": "draft",
- "stale": true,
- "origin": "human",
- "machine_translated_at": "2019-08-24T14:15:22Z",
- "translator_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-242 — idempotent per (article, locale): one article has at most one variant per locale, so this is a PUT rather than a POST and calling it twice leaves one row. Requires articles.manage and the feature.content.multilingual flag. Body HTML is sanitized with the same allowlist as article bodies. Recording a translation reconciles it against the current source body, which clears the stale flag. Creating never publishes — a new variant is always a draft and reaches readers only through the status endpoint. The default locale is refused with 422: the article itself IS the default locale and a variant of it would be a second source of truth for the same URL.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| locale required | string <= 24 characters A BCP-47 locale tag restricted to an ISO 639-1 language with an optional script and/or region — |
| Idempotency-Key | string <= 255 characters |
| title required | string <= 255 characters |
| body | string or null |
| slug | string <= 160 characters Optional — derived from the translated title when omitted. Unique per workspace PER LOCALE, so |
{- "title": "string",
- "body": "string",
- "slug": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "locale": "string",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "body": "string",
- "status": "draft",
- "stale": true,
- "origin": "human",
- "machine_translated_at": "2019-08-24T14:15:22Z",
- "translator_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-242 — requires articles.manage and the feature.content.multilingual flag. Removing a published variant also removes it from the hreflang set and the sitemap of every other locale, because both are computed from the live variants rather than stored.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| locale required | string <= 24 characters A BCP-47 locale tag restricted to an ISO 639-1 language with an optional script and/or region — |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-242 — the independent-publish seam (Doc 03 §5). A locale is published on its own terms: publishing German does not touch the source article or any other locale, and the source article being live does not publish German. Transitioning INTO published requires the same content-publish permission articles use ("creator submits, content_manager approves") and stamps approved_by plus published_at. There is no archived state — a translation that should stop serving goes back to draft and the work is kept.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| locale required | string <= 24 characters |
| Idempotency-Key | string <= 255 characters |
| status required | string Enum: "draft" "in_review" "published" |
{- "status": "draft"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "locale": "string",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "body": "string",
- "status": "draft",
- "stale": true,
- "origin": "human",
- "machine_translated_at": "2019-08-24T14:15:22Z",
- "translator_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-225 — the AI translation workflow (Doc 03 §5 "AI-translate workflow: one click → gateway translates → human review state → publish per locale"; Doc 05 §7 "Multilingual"). Requires articles.manage plus BOTH feature.content.multilingual (the locale surface this writes into) and feature.ai.translation (permission to spend credits writing it); below either flag the route is 404.
THE RESULT IS NEVER PUBLISHED. It lands in in_review — the review state Doc 03 §5 names — so machine text cannot reach a visitor without a human with the content-publish permission transitioning it through the status endpoint. That human action IS the review; there is no path by which this endpoint puts unreviewed text on a public help centre.
A HUMAN'S EDITS ARE NEVER SILENTLY OVERWRITTEN. A translation whose origin is human responds 409 unless force is true, exactly as ATH-091's sync-from-guide refuses to destroy manual article edits. Re-translating a stale translation is therefore an explicit human act, not a background job — see stale.
Structure is verified rather than trusted: the model's output must preserve every code block and every placeholder token from the source. Output that does not is REJECTED (422) and the reason reported, never written and never silently repaired. Output below the confidence floor is rejected the same way. Every attempt — including refusals — appends a row to the translation's provenance history.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| locale required | string <= 24 characters |
| Idempotency-Key | string <= 255 characters |
| force | boolean Default: false Overwrite a translation a human has edited. Default false, which is what makes a re-translation safe to run against a locale someone has corrected by hand. |
{- "force": false
}{- "data": {
- "outcome": "translated",
- "translation": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "locale": "string",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "body": "string",
- "status": "draft",
- "stale": true,
- "origin": "human",
- "machine_translated_at": "2019-08-24T14:15:22Z",
- "translator_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "run_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "credit_cost_cents": 0,
- "forced": true
}
}ATH-091 — update propagation for guide-generated articles (Doc 03 §4): re-renders the body from the guide's current steps and clears diverged_from_guide. If the article body was manually edited since generation, responds 409 unless force is true — a sync must never silently destroy human edits. Title/slug/collection/access/status are left untouched.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| articleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| force | boolean Default: false Overwrite manual body edits |
{- "force": false
}{- "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"
}
}Requires guides.view. 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 |
| articleId 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 |
| rating required | string Enum: "up" "down" |
| comment | string or null <= 2000 characters |
{- "rating": "up",
- "comment": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "rating": "string"
}
}ATH-240 — standalone long-form pages on the help centre (Doc 03 §3). Requires pages.manage and the feature.content.pages flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| status | string Enum: "draft" "in_review" "published" "archived" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "access_level": "public",
- "status": "draft",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
], - "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-240 — requires pages.manage and the feature.content.pages flag. Blocks are validated per type server-side and rich text is sanitized at the write boundary. Slug uniqueness is per WORKSPACE and reserved help-centre path segments are refused with 422.
| 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 |
| title | string <= 255 characters |
| slug | string <= 160 characters Optional — derived from the title when omitted. Lowercase alphanumerics and hyphens only. Unique per workspace and refused when it matches a reserved help-centre segment. |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
Array of any (PageBlock) <= 200 items |
{- "title": "string",
- "slug": "string",
- "access_level": "public",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "access_level": "public",
- "status": "draft",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
], - "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pageId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "access_level": "public",
- "status": "draft",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
], - "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pageId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| title | string <= 255 characters |
| slug | string <= 160 characters Optional — derived from the title when omitted. Lowercase alphanumerics and hyphens only. Unique per workspace and refused when it matches a reserved help-centre segment. |
| access_level | string Enum: "public" "unlisted" "authenticated" "internal" |
Array of any (PageBlock) <= 200 items |
{- "title": "string",
- "slug": "string",
- "access_level": "public",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "access_level": "public",
- "status": "draft",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
], - "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-240 — requires pages.manage and the feature.content.pages flag. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant’s rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pageId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-240 — pages reuse the article workflow (Doc 03 §3 "the same publishing/permissions machinery"): "creator submits, content_manager approves", so transitioning INTO published additionally requires the content-publish permission and stamps approved_by + published_at. Only a published page renders on the public help centre; every other status 404s to visitors.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pageId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| status required | string Enum: "draft" "in_review" "published" "archived" |
{- "status": "draft"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "url_path": "string",
- "access_level": "public",
- "status": "draft",
- "blocks": [
- {
- "type": "rich_text",
- "html": "string"
}
], - "author_id": "string",
- "approved_by": "string",
- "published_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-241 — verification workflows (Doc 03 §2.1). Requires content.verify and the feature.content.verification flag; below the flag every route in this group is 404. Scoped to the workspace in the path — a member of another workspace gets 403.
This is the review QUEUE: filter by status=due for the work a content manager has to do. Ordered by due_at ascending so the most overdue is first, and cursor-paginated because a workspace can put its whole help centre on a cadence.
PA-253 — filter by unowned=true for content whose owner left the workspace. Ownership is nulled when a member is removed, and this is where that content stays visible so a null owner is surfaced rather than silent: it is orthogonal to status, so an in-date schedule with no owner still shows up here.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| status | string Enum: "verified" "due" "expired" |
| content_type | string Enum: "article" "page" "guide" |
| owner_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: owner_id=01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| unowned | boolean PA-253 — when true, return only schedules with no owner (owner_id null), the state a departed owner leaves behind. |
| limit | integer [ 1 .. 100 ] Default: 25 |
| cursor | string |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "owner_id": "string",
- "interval_days": 0,
- "status": "verified",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "due_at": "2019-08-24T14:15:22Z",
- "records": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "interval_days": 0,
- "previous_status": "verified",
- "note": "string"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-241 — requires content.verify and the feature.content.verification flag. 404 when the content has no review schedule; the content itself must live in the workspace in the path.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" "guide" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "owner_id": "string",
- "interval_days": 0,
- "status": "verified",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "due_at": "2019-08-24T14:15:22Z",
- "records": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "interval_days": 0,
- "previous_status": "verified",
- "note": "string"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-241 — requires content.verify and the feature.content.verification flag. Creates the schedule when the content has none and updates the owner or cadence when it does. Creating a schedule counts as a first verification: it is stamped verified by the caller now and due_at becomes now plus interval_days. CHANGING the cadence later re-bases due_at on the last verification rather than on the present, so shortening an interval can make a review immediately due — which is the point of shortening it.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" "guide" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| interval_days required | integer [ 1 .. 3650 ] |
| owner_id | string or null Must be a member of this workspace. |
{- "interval_days": 1,
- "owner_id": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "owner_id": "string",
- "interval_days": 0,
- "status": "verified",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "due_at": "2019-08-24T14:15:22Z",
- "records": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "interval_days": 0,
- "previous_status": "verified",
- "note": "string"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-241 — requires content.verify and the feature.content.verification flag. Removes the schedule so the sweep stops raising reviews. The attestation records go with it: the trail exists to support a live claim about live content, and keeping orphaned rows after the schedule is deliberately dropped would outlive the claim they document.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" "guide" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-241 — the re-verify action (Doc 03 §2.1). Requires content.verify and the feature.content.verification flag. Stamps the caller as verified_by, resets status to verified and pushes due_at out by interval_days from now. Appends an immutable record naming who attested and when; that trail is the feature, so the record is written in the same transaction as the status change and neither can land without the other.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" "guide" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| note | string or null <= 2000 characters |
{- "note": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "owner_id": "string",
- "interval_days": 0,
- "status": "verified",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "due_at": "2019-08-24T14:15:22Z",
- "records": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verified_by": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "interval_days": 0,
- "previous_status": "verified",
- "note": "string"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-024 — snapshot history (Doc 03 §2.1). Requires guides.publish (the content-publish permission, which ArticleController and PageController already use to gate publishing) and the feature.content.versioning flag.
contentType is article or page and NOT guide: guides keep their own history at /guides/{guideId}/versions, on a separate table, because a guide's body is guide_steps rows rather than a column and Doc 03 §2.1 mandates a never-delete plan window for them that is incompatible with the storage cap here.
Retention is a hard cap of the newest N per content item (default 20). Beyond the cap the OLDEST snapshots are deleted; numbers are never reused, so a pruned version 404s rather than resolving to a different snapshot.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "number": 0,
- "label": "string",
- "created_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "title": "string",
- "slug": "string",
- "status": "string",
- "access_level": "string",
- "block_count": 0,
- "body_length": 0
}
]
}ATH-024 — the stored snapshot as it was captured. Requires guides.publish and the feature.content.versioning flag.
The payload is what was captured, which is deliberately WIDER than what a restore puts back: it records status and access_level for the audit trail, and restore never reapplies either (see the restore operation).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| number required | integer >= 1 |
{- "data": {
- "number": 0,
- "label": "string",
- "created_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "status": "string",
- "access_level": "string",
- "block_count": 0,
- "body_length": 0,
- "snapshot": { }
}
}ATH-024 — restore (Doc 03 §2.1, §8 scenario 8). Requires guides.publish and the feature.content.versioning flag. Publish-level and not merely edit-level on purpose: one call replaces the body of content that may be live to the public, with no review step, so the role trusted to approve what goes live is the role trusted to roll it back.
WHAT IS RESTORED is the CONTENT only — title, body or blocks, and (for a page) the slug. status, published_at, approved_by and access_level are left exactly as they are, so a rollback can undo an authoring mistake but can never silently republish archived content or widen who may read it.
WHAT REFUSES: the snapshot is re-validated through the same code an ordinary write runs — block shape and referents for a page, HTML sanitization and collection existence for an article — and a snapshot that would no longer be accepted as a fresh write is rejected 422 with NOTHING written. A block type since retired, an embedded guide since deleted, an image since removed, a slug since taken by another page: each refuses loudly rather than persisting a body the renderer cannot draw onto a live public page.
Restoring is itself undoable: the state being overwritten is snapshotted first, in the same transaction.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "article" "page" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| number required | integer >= 1 |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "number": 0,
- "label": "string",
- "created_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "content_type": "article",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "status": "string",
- "access_level": "string",
- "block_count": 0,
- "body_length": 0,
- "snapshot": { }
}
}ATH-099 — Postgres FTS (tsvector + ts_rank) in production, LIKE fallback on other drivers; Meilisearch swaps in at P4 behind the same shape. Members see every published article regardless of access level (they are workspace members); the public help-center search applies visitor access rules instead.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| q required | string [ 2 .. 200 ] characters |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "slug": "string",
- "collection_id": "string",
- "access_level": "public",
- "excerpt": "string"
}
]
}Called by the help-center ingress (Caddy on_demand_tls "ask" or a platform equivalent) before minting a certificate. 200 iff the hostname has a VERIFIED custom-domain row. Shared-secret token in the query because the ask directive sends no headers; the token unset disables the endpoint (fail closed). Not a client API.
| domain required | string |
| token required | string |
{- "data": {
- "authorized": true
}
}ATH-224 (Doc 05 §7, Doc 03 §5) — a synthesized, cited answer over the workspace's PUBLIC published articles, shown above the ordinary link results on the hosted help centre.
Served ONLY on the help-centre host — {slug}.cairnhelp.com or a verified ATH-093 custom domain — and the route is domain-scoped to those hosts. The tenant therefore comes from the host alone: there is no bearer token, no workspace id in the path, and no widget key, so there is no parameter a caller could change to reach another workspace's content.
Retrieval is pinned to the public audience and every hit is re-verified against its live article row (published + public) before it may ground or be cited, so internal, unlisted, authenticated, and draft content can reach neither the answer nor the citations.
Unauthenticated and LLM-backed, so it is the most abusable surface in the product: throttled per IP+host, query capped at 200 characters, answers cached per workspace under the help-centre content version, and metered against the workspace that owns the help centre (ATH-102/166).
ADVISORY by contract: the link results come from the server-rendered /search page and never depend on this call. Flag off, plan below the AI suite, exhausted credits, and generation failures all return 200 with status: unavailable — a public site must never show a visitor a 402 because its owner ran out of credits.
| q required | string [ 2 .. 200 ] characters |
{- "data": {
- "status": "answered",
- "answer": "string",
}
}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": {
- "logo_media_id": "string",
- "header_links": [
- {
- "label": "string",
- "url": "string"
}
], - "custom_css": "string",
- "white_label": true
}
}ATH-094 — custom_css and white_label are Scale+ entitlements (feature.content.custom_css / feature.content.white_label): setting them on a lower plan is a 422. Colors and fonts come from brand profiles (ATH-083), not this endpoint.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| logo_media_id | string or null |
Array of objects <= 8 items | |
| custom_css | string or null <= 20000 characters |
| white_label | boolean |
{- "logo_media_id": "string",
- "header_links": [
- {
- "label": "string",
- "url": "string"
}
], - "custom_css": "string",
- "white_label": true
}{- "data": {
- "logo_media_id": "string",
- "header_links": [
- {
- "label": "string",
- "url": "string"
}
], - "custom_css": "string",
- "white_label": true
}
}ATH-093 — Doc 03 §5. Requires settings.manage. Each row carries the cname_target tenants must point their CNAME at; status moves pending → verified/failed via the async DNS check.
| 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",
- "hostname": "string",
- "status": "pending",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "cname_target": "string",
- "certificate_status": "string",
- "certificate_issued_at": "2019-08-24T14:15:22Z"
}
]
}ATH-093 — Doc 03 §5. Requires settings.manage and the feature.content.custom_domain entitlement (Scale+; 422 below it). The hostname must be a valid FQDN, not on an Cairn-owned zone, and globally unused. Verification is asynchronous: the row returns pending and flips once the CNAME check runs.
| 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 |
| hostname required | string Fully-qualified hostname, e.g. help.customer.com. Lowercased on save. |
{- "hostname": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "hostname": "string",
- "status": "pending",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "cname_target": "string",
- "certificate_status": "string",
- "certificate_issued_at": "2019-08-24T14:15:22Z"
}
}ATH-093 — removing the row immediately stops host resolution and TLS-issuance authorization for the hostname.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| domainId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-093 — the doc's "verification check endpoint". Queues a fresh DNS check; poll listDomains for the outcome. Throttled.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| domainId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "hostname": "string",
- "status": "pending",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "cname_target": "string",
- "certificate_status": "string",
- "certificate_issued_at": "2019-08-24T14:15:22Z"
}
}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.
| 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"
}
}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-090 — sections become headings, steps become numbered instructions with screenshots; the article stays linked to the guide and diverged_from_guide flips when the guide changes.
| guideId 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 |
| collection_id | string or null |
{- "collection_id": "string"
}{- "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"
}
}PA-231 — Doc 07 §5. Requires helpcenter.manage and the feature.audiences.variants flag; below the flag every route in this group is 404. Returns the fixed trio (public, customers, internal) plus any Enterprise custom audiences, system rows first.
| 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",
- "key": "string",
- "name": "string",
- "is_system": true,
- "retrieval_scope": "public",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}PA-231 — Doc 07 §5. Requires helpcenter.manage, the feature.audiences.variants flag, and the Enterprise feature.audiences.custom entitlement (422 below it, ATH-094 pattern). key may not collide with the reserved trio.
| 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 |
| key required | string [ 2 .. 32 ] characters ^[a-z][a-z0-9_]*$ Stable slug; may not be one of the reserved trio keys. |
| name required | string [ 1 .. 80 ] characters |
| retrieval_scope required | string Enum: "public" "customers" "internal" The base scope a custom audience inherits; fails closed to internal. |
{- "key": "string",
- "name": "string",
- "retrieval_scope": "public"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "key": "string",
- "name": "string",
- "is_system": true,
- "retrieval_scope": "public",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-231 — partial update of a CUSTOM audience's name and base retrieval scope. System trio rows are immutable (422). Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| audienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 80 ] characters |
| retrieval_scope | string Enum: "public" "customers" "internal" |
{- "name": "string",
- "retrieval_scope": "public"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "key": "string",
- "name": "string",
- "is_system": true,
- "retrieval_scope": "public",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-231 — deletes a CUSTOM audience; its publications cascade, so content is unpublished from it and that audience's chunks/media variants clear. System trio rows cannot be deleted (422). Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| audienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-231 — Doc 07 §5. The per-audience variant matrix for one guide or article: which audiences it is published to, each variant's redaction profile and freshness SLA. Requires helpcenter.manage + the flag; an unknown content type is 404.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "guide" "article" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "redaction_profile": [
- "pii.email"
], - "verify_interval_days": 0,
- "locale_set": [
- "string"
], - "theme": { },
- "published": true,
- "published_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}PA-231 — Doc 07 §5. Idempotent by (content, audience): sets this audience's redaction profile (the Doc 02 §7 categories its media variant auto-blurs at render), per-audience verify_interval_days, and publish state, then re-indexes the audience's chunks and renders its blurred media variant. Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "guide" "article" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| audienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| redaction_profile | Array of strings (RedactionCategory) Default: [] Items Enum: "pii.email" "pii.phone" "pii.ssn" "pii.card" "pii.iban" "pii.name" "pii.address" "secret.api_key" |
| verify_interval_days | integer or null [ 1 .. 3650 ] |
| locale_set | Array of strings or null |
| theme | object or null |
| published | boolean Default: true false unpublishes from the audience without deleting the row. |
{- "redaction_profile": [ ],
- "verify_interval_days": 1,
- "locale_set": [
- "string"
], - "theme": { },
- "published": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "redaction_profile": [
- "pii.email"
], - "verify_interval_days": 0,
- "locale_set": [
- "string"
], - "theme": { },
- "published": true,
- "published_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-231 — removes the publication and clears that audience's chunks and media variant. Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "guide" "article" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| audienceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-232 (ATH-315) — Doc 07 §5 governance console. One row per content item that carries any audience publication, one cell per audience: published?, redaction profile, translation freshness, verification status (computed against the per-audience verify_interval_days override so a shorter per-audience SLA can read due while the content-level schedule reads verified). Assembled in PHP — no JSON SQL predicates. Requires helpcenter.manage and the feature.audiences.governance flag; below the flag every route in this group is 404. Page-paginated (fetch limit+1 to detect the next page) because a governed corpus is bounded per workspace.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| content_type | string Enum: "guide" "article" |
| limit | integer [ 1 .. 100 ] Default: 25 |
| page | integer >= 1 Default: 1 |
{- "data": [
- {
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "string",
- "verification_status": "verified",
- "cells": [
- {
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "published": true,
- "redaction_profile": [
- "pii.email"
], - "verify_interval_days": 0,
- "verification_status": "verified",
- "translation_freshness": "fresh"
}
]
}
], - "meta": {
- "audiences": [
- {
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "key": "string",
- "name": "string",
- "is_system": true,
- "retrieval_scope": "public"
}
], - "page": 0,
- "per_page": 0,
- "has_more": true
}
}PA-232 (ATH-315) — Doc 07 §5 bulk operations. Each operation is applied through the SAME per-audience publish path as the single PA-231 endpoint (SyncAudiencePublications reconciles the corpus), so the bulk op can never leak a variant the single write would not. PARTIAL SUCCESS with per-item results: one bad item (foreign id, or a public-scope publish refused by the Enterprise policy gate) fails that item alone and the rest still apply — the response reports each item's outcome. Idempotent: re-sending the same operation leaves the same state. Requires helpcenter.manage + the flag.
| 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 |
required | Array of objects (BulkAudiencePublishOperation) [ 1 .. 100 ] items |
{- "operations": [
- {
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "published": true,
- "redaction_profile": [
- "pii.email"
], - "verify_interval_days": 1
}
]
}{- "data": {
- "results": [
- {
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "ok": true,
- "status": "published",
- "error": "string",
- "message": "string"
}
]
}
}PA-232 (ATH-315) — Doc 07 §5 "audit trail of audience changes". Reads the append-only audit_logs (ATH-013) filtered to this workspace's audience.* actions, newest first. Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| limit | integer [ 1 .. 100 ] Default: 25 |
| cursor | string |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "action": "string",
- "actor_type": "user",
- "actor_id": "string",
- "content_type": "string",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "audience_key": "string",
- "metadata": { },
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Reports whether this content has the two things a public-scope publish requires under the Enterprise feature.audiences.public_policy: a recorded redaction scan pass AND a content_manager approval. data is null when no clearance has been recorded. Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "guide" "article" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "redaction_scan_passed": true,
- "redaction_scan_at": "2019-08-24T14:15:22Z",
- "approved": true,
- "approved_by": "string",
- "approved_at": "2019-08-24T14:15:22Z",
- "cleared": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-232 (ATH-315) — Doc 07 §5 Enterprise policy. Idempotent by content. approved:true records the acting content_manager (the route's helpcenter.manage gate IS the content_manager-tier check) as the approver; approved:false withdraws it. redaction_scan_passed records the scan signal — set here manually until PA-233 automates the leak scan that will own this field. Clearance requires BOTH. Requires helpcenter.manage + the flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| contentType required | string Enum: "guide" "article" |
| contentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| approved | boolean true records the acting content_manager as approver; false withdraws. |
| redaction_scan_passed | boolean Records (or clears) the redaction-scan-pass signal; PA-233 will own this. |
{- "approved": true,
- "redaction_scan_passed": true
}{- "data": {
- "content_type": "guide",
- "content_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "redaction_scan_passed": true,
- "redaction_scan_at": "2019-08-24T14:15:22Z",
- "approved": true,
- "approved_by": "string",
- "approved_at": "2019-08-24T14:15:22Z",
- "cleared": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires inbox.view. 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 |
| state | string Enum: "pending" "open" "snoozed" "resolved" |
| assignee_id | string |
| inbox_id | string |
| kind | string Enum: "conversation" "ticket" ATH-214 — narrow the same list to tickets (or to non-ticket conversations) |
| quarantined | boolean ATH-221 — the spam folder. Omitted or false, quarantined conversations are EXCLUDED, which is the whole benefit of quarantine: this list is capped at 100 rows, so leaving spam in it would push real customers off the end. Pass true to list only the quarantined ones and release false positives from there. |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "pending",
- "channel": "string",
- "subject": "string",
- "assignee_id": "string",
- "contact": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "string",
- "verified": true
}, - "preview": "string",
- "rating": 1,
- "last_message_at": "2019-08-24T14:15:22Z",
- "kind": "conversation",
- "reference": "string",
- "priority": "string",
- "sla_breached": true,
- "quarantined": true,
- "translate_enabled": true
}
]
}Requires inbox.view. 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 |
| conversationId 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",
- "direction": "in",
- "author_type": "string",
- "author_id": "string",
- "body": "string",
- "translation": {
- "body": "string",
- "target_locale": "string",
- "source_locale": "string",
- "audience": "agent",
- "confidence": 0,
- "prompt_version": "string",
- "credit_cost_cents": 0
}, - "read_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-131 — note=true writes a private note: it NEVER publishes on the conversation channel (the visitor holds it) — notes ride the agent-only workspace inbox channel.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| body required | string <= 10000 characters |
| note | boolean Default: false |
{- "body": "string",
- "note": false
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "direction": "out",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires inbox.view. 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 |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "contact": { },
- "visitor": { },
- "history": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "string",
- "channel": "string",
- "rating": 0,
- "last_message_at": "2019-08-24T14:15:22Z"
}
], - "summary": {
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "summary": "string",
- "key_points": [
- "string"
], - "message_count": 0,
- "stale": true,
- "generated_at": "2019-08-24T14:15:22Z",
- "trigger": "manual",
- "prompt_version": "string",
- "credit_cost_cents": 0
}, - "tags": [
- {
- "tag": "string",
- "source": "ai",
- "confidence": 0,
- "applied_at": "2019-08-24T14:15:22Z",
- "applied_by": "string"
}
], - "spam": {
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verdict": "spam",
- "quarantined": true,
- "source": "ai",
- "confidence": 0,
- "reason": "string",
- "classified_at": "2019-08-24T14:15:22Z",
- "prompt_version": "string",
- "reversed_at": "2019-08-24T14:15:22Z",
- "reversed_by": "string"
}
}
}ATH-220 (Doc 05 §7). The agent-FACING assistant: it answers a teammate's question about the conversation or the knowledge base, drafts a reply for that teammate to edit and send, or rewrites their draft in a different tone. It never sends anything — the human sends.
Requires inbox.reply and the feature.ai.full_suite plan entitlement; below the flag the surface 404s. Unlike the customer-facing agent this path retrieves internal audience knowledge as well as public and customers, so its answers may quote material a customer must never see. Every call is metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402 rather than a silent failure.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| mode required | string Enum: "ask" "draft" "rewrite" ask = answer a teammate's question; draft = propose a reply to the customer; rewrite = restyle the teammate's own text |
| question | string or null <= 2000 characters Required for |
| text | string or null <= 10000 characters Required for |
| tone | string Enum: "formal" "friendly" "shorten" Required for |
{- "mode": "ask",
- "question": "string",
- "text": "string",
- "tone": "formal"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "mode": "ask",
- "text": "string",
- "sources": [
- {
- "kind": "article",
- "ref": "string",
- "title": "string",
- "slug": "string",
- "audience": "public"
}
], - "grounded": true,
- "confidence": 0,
- "credit_cost_cents": 0
}
}ATH-221 (Doc 05 §7). Summarises the thread for a teammate picking it up cold. Idempotent per conversation — there is one summary row, and a repeat call REGENERATES it in place against the transcript as it now stands. A summary generated before later messages arrived is marked stale so nobody acts on a stale picture without knowing it is one.
Requires inbox.reply and the feature.ai.full_suite plan entitlement plus the feature.ai.summaries rollout flag; below either the surface 404s. Metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "summary": "string",
- "key_points": [
- "string"
], - "message_count": 0,
- "stale": true,
- "generated_at": "2019-08-24T14:15:22Z",
- "trigger": "manual",
- "prompt_version": "string",
- "credit_cost_cents": 0
}
}ATH-223 (Doc 04 §9). The per-conversation auto-translate toggle. Switching it ON translates subsequent messages in BOTH directions — inbound into agent_locale for the team, outbound into the customer's detected language for them — and backfills a bounded window of the existing thread so the agent can read what was said before they arrived.
Translation never blocks delivery. Messages are written and broadcast first and translated afterwards on a queued job, so a gateway outage or an exhausted balance degrades the conversation to untranslated rather than breaking it. Nothing on this surface returns 402 for that reason: the trigger for a translation is a customer sending a message, and a customer is not the person who can top up a workspace's credits.
Switching it OFF stops future translations and deletes nothing — existing translations stay readable, because a thread an agent has been reading in English should not become unreadable mid-conversation.
Requires inbox.reply and the feature.ai.full_suite plan entitlement plus the feature.ai.live_translate rollout flag; below either the surface 404s.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| enabled required | boolean |
| agent_locale | string <= 24 characters The language the team reads this thread in. Required when enabling; a BCP-47 tag whose language subtag is on ATH-242's closed ISO 639-1 allowlist. |
{- "enabled": true,
- "agent_locale": "string"
}{- "data": {
- "enabled": true,
- "agent_locale": "string",
- "visitor_locale": "string"
}
}ATH-261 (Doc 04 §9). An agent requests a live co-browse session on a widget conversation. This does NOT start streaming: it creates a pending session and prompts the visitor, who must EXPLICITLY accept before a single frame is relayed (consent is mandatory, and the server refuses frames until it is given). The session auto-expires after a bounded window, and either party can end it.
The visitor's screen is serialised with rrweb and relayed as EPHEMERAL realtime events on the conversation channel — never stored. Password fields and anything the site marks [data-private] are masked in the visitor's browser before serialisation, so the raw values never leave the page (CLAUDE.md rule 3 in a live medium). This session row is the consent + lifecycle record only.
Only widget conversations carry a visitor to watch; other channels 422. Requires inbox.reply and the feature.messaging.cobrowse flag (404 below it).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "channel": "string",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-261 — the agent stops watching. Idempotent: ending an already-ended session returns its terminal state rather than erroring. Publishes cobrowse.ended so the visitor's browser stops capturing at once. Requires inbox.reply.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| sessionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "channel": "string",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}PA-261 (Doc 07 §3 / cobrowse→guide, Option B 2 of 3). On an ACTIVE, consented co-browse session, the agent proposes recording it into an editable guide. This does NOT start recording: it creates a pending guide-capture request and prompts the visitor, who must give a DISTINCT, explicit consent — separate from the co-browse view consent — on the widget before a single step is ingested. Any prior live guide-capture request on this session is superseded (revoked). Requires inbox.reply and the feature.cobrowse.guide_capture flag (404 below it). 422 if the co-browse session is not active.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| sessionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cobrowse_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "requested",
- "requested_at": "2019-08-24T14:15:22Z",
- "consented_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}ATH-262 (Doc 04 §9) — an agent asks the visitor onto a LiveKit call. This does NOT open anyone's microphone: it creates a pending session and prompts the visitor, who must EXPLICITLY accept. No media token is minted for EITHER participant until they do, so an agent cannot reach a visitor's mic or camera without their consent. The visitor may narrow what they accept (a video request accepted as audio) but never widen it.
Media itself flows peer-to-SFU through LiveKit and never touches Cairn's realtime fabric; only the invite/accept/end signalling rides the existing conversation and visitor channels. Nothing is recorded — see CallToken for the grant that says so.
Only widget conversations carry a visitor to call; other channels 422. Requires inbox.reply and the feature.messaging.av_calls flag (404 below it).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| media | string Default: "audio" Enum: "audio" "video" What the agent is asking for. |
{- "media": "audio"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "media": "audio",
- "initiated_by": "agent",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-262 — the agent's join credential. Refused with 409 unless the session is joinable: active, carrying a non-null consented_at, unexpired and unended. A pre-consent token request gets nothing, which is the guarantee — the agent cannot enter the room before the visitor has said yes.
The token is a short-lived JWT signed with the LiveKit API secret (which never leaves the server), scoped to exactly one room and one identity, and it never outlives the call session's own window. Re-request it on reconnect: every issue re-checks consent and expiry, so our server stays the authority rather than a long-lived bearer. Requires inbox.reply.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| callId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "token": "string",
- "url": "string",
- "room": "string",
- "identity": "string",
- "media": "audio",
- "expires_at": "2019-08-24T14:15:22Z"
}
}ATH-262 — the agent hangs up. Idempotent: ending an already-ended call returns its terminal state rather than erroring. Publishes call.ended so the visitor's browser tears its LiveKit room down, and asks LiveKit to delete the room so no participant lingers in it. Requires inbox.reply; deliberately NOT flag-gated, so a live call can always be stopped.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| callId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "media": "audio",
- "initiated_by": "agent",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-223 (Doc 04 §9, "glossary support"). A flat term → rendering map applied to every LiveTranslate call for this workspace. Workspace-wide rather than per conversation because a glossary answers "what do we call our own product in German", and that answer does not change between two threads — per-conversation glossaries would produce the exact inconsistency a glossary exists to prevent.
A full replacement, not a merge: sending {} clears it. Terms are interpolated into the model prompt, so they are length-capped and count-capped.
Requires ai.configure and the feature.ai.full_suite plan entitlement plus the feature.ai.live_translate rollout flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
required | object <= 200 properties term → preferred rendering in the target language. |
{- "glossary": {
- "property1": "string",
- "property2": "string"
}
}{- "data": {
- "glossary": {
- "property1": "string",
- "property2": "string"
}
}
}ATH-221 (Doc 05 §7 "classifier call on resolve"). This endpoint APPLIES tags, it does not merely propose them — Doc 05 §7 says auto-tagging "powers analytics + mining", and a suggestion queue would mean analytics saw only the subset a human got round to approving.
Because it applies them, every tag carries PROVENANCE: source records whether a model, a human, or a workflow put it there, and the assignment log is append-only, so "the AI tagged this conversation refund and a human removed it" is reconstructible after the fact. The model may only choose from tags the workspace already uses — it never invents vocabulary. Removing a wrong tag is POST .../tags with remove.
Requires inbox.reply, feature.ai.full_suite and the feature.ai.auto_tag rollout flag. Metered; 402 when exhausted.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "applied": [
- "string"
], - "skipped": [
- {
- "tag": "string",
- "reason": "not_in_vocabulary"
}
], - "tags": [
- {
- "tag": "string",
- "source": "ai",
- "confidence": 0,
- "applied_at": "2019-08-24T14:15:22Z",
- "applied_by": "string"
}
], - "vocabulary_empty": true,
- "credit_cost_cents": 0
}
}ATH-221 — the human half of the tag surface, and the correction path for anything auto-tagging got wrong. Writes carry source: human and the acting user, so the assignment log distinguishes what a model did from what a person did.
No AI call, no credits. Requires inbox.reply; needs neither AI flag, because tagging by hand is not an AI feature and must keep working when the utilities are switched off.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| add | Array of strings <= 20 items [ items <= 50 characters ] |
| remove | Array of strings <= 20 items [ items <= 50 characters ] |
{- "add": [
- "string"
], - "remove": [
- "string"
]
}{- "data": {
- "tags": [
- {
- "tag": "string",
- "source": "ai",
- "confidence": 0,
- "applied_at": "2019-08-24T14:15:22Z",
- "applied_by": "string"
}
]
}
}ATH-221 — the reversal path for the spam filter, and the manual quarantine control (Doc 05 §7).
The asymmetry that shapes this surface: a false positive hides a real customer, so spam is QUARANTINED and never deleted. The conversation, its messages and its contact all survive untouched; only quarantined_at changes, and the thread stays readable through every existing endpoint. spam: false releases it — the row is restored to the inbox, the correction is recorded against the original verdict, and the conversation is never re-classified afterwards, so releasing something is final rather than a thing the model can undo on the next message.
No AI call, no credits — this is a human overriding a classifier. Requires inbox.reply and no AI flag: a workspace that switches the spam filter off must still be able to release whatever it quarantined while it was on.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| spam required | boolean true quarantines the conversation; false releases it back to the inbox. |
| reason | string or null <= 500 characters Optional note recorded on the verdict — why a human disagreed. |
{- "spam": true,
- "reason": "string"
}{- "data": {
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "verdict": "spam",
- "quarantined": true,
- "source": "ai",
- "confidence": 0,
- "reason": "string",
- "classified_at": "2019-08-24T14:15:22Z",
- "prompt_version": "string",
- "reversed_at": "2019-08-24T14:15:22Z",
- "reversed_by": "string"
}
}ATH-222 — voice-to-text (Doc 05 §7). A teammate holds the mic button in the inbox composer, speaks a reply, and gets text back to edit and send. The transcript is RETURNED, never sent: it lands in the composer through ATH-220's insertion seam and a human presses send, exactly as with a copilot draft.
AUDIO IS NOT RETAINED. The recording is staged privately, transcribed, and deleted before this endpoint responds — on the failure path too. No media row is written and there is nothing to fetch afterwards, by design: a dictated reply routinely speaks a customer's details aloud, and a voice is personal data in its own right. The transcript is the artefact; the audio is a courier (CLAUDE.md rule 3, transposed from typed input to spoken).
Metered by DURATION, not tokens (Doc 05 §9, "Voice-to-text (per min) 1¢"), charged in whole minutes rounded up, against the longer of the client's declared duration and the duration the provider actually transcribed. Requires inbox.reply, the feature.ai.full_suite plan entitlement and the feature.ai.voice_to_text rollout flag; below either the surface 404s. An exhausted workspace gets 402.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| file required | string <binary> The recording — webm/ogg/mp3/m4a/mp4/wav/flac, max 10 MB. Anything else is 422. |
| duration_ms required | integer [ 1 .. 120000 ] Recording length as the browser measured it, used to gate credits before the call. Capped at two minutes; longer is 422. It is a floor on the price, not the price — the debit re-derives duration from the transcript. |
| language | string or null <= 8 characters BCP-47 hint for the recogniser. Omitted, the provider detects it. |
{- "data": {
- "text": "string",
- "duration_seconds": 0,
- "minutes_charged": 0,
- "credit_cost_cents": 0
}
}Requires inbox.reply. 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 |
| conversationId 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 |
| state required | string Enum: "pending" "open" "snoozed" "resolved" |
{- "state": "pending"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "string"
}
}Requires inbox.reply. 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 |
| conversationId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-132 — round_robin=true picks the eligible agent (a role holding inbox.reply) with the fewest active conversations, tie-broken deterministically. Publishes conversation.assigned.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| assignee_id | string or null |
| round_robin | boolean Default: false |
{- "assignee_id": "string",
- "round_robin": false
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "assignee_id": "string"
}
}ATH-013 — shipped undocumented; found by the spec↔route parity test. Requires inbox.configure. Bans the VISITOR, not the conversation: the thread stays, the person can't start new ones. 422 when the conversation has no widget visitor (an email thread has nobody to ban).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| conversationId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "banned": true
}
}Requires inbox.view — every agent needs them. Scoped to the workspace in the path. q prefix-matches the bang.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| q | string <= 32 characters |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "bang": "string",
- "title": "string",
- "body": "string"
}
]
}Requires inbox.configure — reading shortcuts is every agent's business, authoring them is configuration.
| 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 |
| bang required | string <= 32 characters ^[a-z0-9_-]+$ |
| title required | string <= 255 characters |
| body required | string <= 5000 characters |
{- "bang": "string",
- "title": "string",
- "body": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}Requires inbox.configure. Scoped to the workspace in the path.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| shortcutId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Requires inbox.view. Reminders are PERSONAL: every route here operates on the calling user's reminders only, never the workspace's. Due processing is cairn:process-reminders.
| 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",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "remind_at": "2019-08-24T14:15:22Z",
- "note": "string"
}
]
}Requires inbox.reply. The reminder belongs to the CALLER.
| 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 |
| conversation_id required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| remind_at required | string <date-time> Must be in the future. |
| note | string or null <= 500 characters |
{- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "remind_at": "2019-08-24T14:15:22Z",
- "note": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}Requires inbox.reply. Only the caller's own reminders are reachable.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| reminderId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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": {
- "inbound_address": "string",
- "custom_domain": {
- "status": "unconfigured",
- "dns_records": [
- {
- "purpose": "SPF",
- "type": "string",
- "name": "string",
- "value": "string"
}
]
}
}
}Postmark-shape payload; authenticated by the shared provider secret in X-Cairn-Email-Token (constant-time compare). The token path segment routes to the workspace. Not a client API.
| token required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Telegram Update objects (message / edited_message). The workspace id in the path routes the update — the founder registers this exact URL via setWebhook, one bot per workspace — and the per-workspace secret in X-Telegram-Bot-Api-Secret-Token authenticates Telegram (constant-time compare against the vault credential). 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad secret. Telegram sends no delivery receipts, so there is no status sibling. Not a client API.
| workspaceId required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's GET verification handshake. Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the workspace's vault verify_token (constant-time compare). The workspace id in the path routes the check — the founder registers this exact URL as the app's callback, one phone number per workspace. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 403 on a bad or missing verify token. Not a client API.
| workspaceId required | string |
| hub.mode | string |
| hub.verify_token | string |
| hub.challenge | string |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's entry[].changes[].value envelope carrying messages[] (inbound text/media) and/or statuses[] (sent/delivered/read/failed delivery receipts). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message wamid. Not a client API.
| workspaceId required | string |
| X-Hub-Signature-256 required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's GET verification handshake (shared by every Messenger Platform product). Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the workspace's vault verify_token (constant-time compare). The workspace id in the path routes the check — the founder registers this exact URL as the Meta app's Messenger callback, one Facebook Page per workspace. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 403 on a bad or missing verify token. Not a client API.
| workspaceId required | string |
| hub.mode | string |
| hub.verify_token | string |
| hub.challenge | string |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's entry[].messaging[] envelope (object=page) carrying message events (text/attachments), delivery receipts (delivery.mids), and read watermarks (read.watermark). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message mid. Not a client API.
| workspaceId required | string |
| X-Hub-Signature-256 required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's GET verification handshake for the Instagram messaging product — identical to the Messenger handshake but keyed on the workspace's Instagram vault credential. Echoes hub.challenge (text/plain) when hub.mode=subscribe and hub.verify_token matches the vault verify_token (constant-time compare). The founder registers this exact URL as the Meta app's Instagram callback, one Instagram professional account per workspace. 404 when the channel is not activated; 403 on a bad or missing verify token. Not a client API.
| workspaceId required | string |
| hub.mode | string |
| hub.verify_token | string |
| hub.challenge | string |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Meta's entry[].messaging[] envelope (object=instagram) carrying message events (text/attachments) and read receipts (read.mid — the Instagram read event names the specific message, unlike Messenger's watermark). The workspace id in the path routes the event; the payload is authenticated by X-Hub-Signature-256 — HMAC-SHA256 over the raw body with the workspace app_secret, constant-time compare. 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Redelivery is idempotent through the message mid. Not a client API.
| workspaceId required | string |
| X-Hub-Signature-256 required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Twilio's inbound SMS/MMS webhook. UNLIKE the other channels this body is application/x-www-form-urlencoded, not JSON — Twilio posts From, To, Body, MessageSid, and NumMedia / MediaUrl{N} + MediaContentType{N} for MMS. The sender's From phone (E.164) is the contact key and the thread; MessageSid is the channel_message_id (redelivery dedupe). The workspace id in the path routes the message — the founder registers this exact URL on the Twilio number's Messaging configuration, one number per workspace — and the payload is authenticated by X-Twilio-Signature (HMAC-SHA1 over the full URL plus the alphabetically-sorted POST params, base64, keyed by the workspace auth token; constant-time compare). 404 when the channel is not activated (no vault credential, or the omnichannel plan gate is off); 401 on a bad or missing signature. Not a client API.
| workspaceId required | string |
| X-Twilio-Signature required | string |
| MessageSid | string |
| From | string |
| To | string |
| Body | string |
| NumMedia | string |
| MediaUrl0 | string |
| MediaContentType0 | string |
| property name* additional property | any |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Twilio's delivery status callback for an outbound message, also application/x-www-form-urlencoded: MessageSid + MessageStatus (queued/sent/delivered/undelivered/failed). The workspace id in the path routes the callback — the founder sets this exact URL as the StatusCallback on the Twilio number / Messaging Service — and it is authenticated by X-Twilio-Signature exactly as the inbound webhook. delivered/read move the message forward; undelivered/failed record a delivery failure; the weaker queued/sending/sent are ignored (the optimistic delivered_at the send already stamped stands). 404 when the channel is not activated; 401 on a bad or missing signature. Not a client API.
| workspaceId required | string |
| X-Twilio-Signature required | string |
| MessageSid | string |
| MessageStatus | string |
| ErrorCode | string |
| property name* additional property | any |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Twilio's inbound CALL webhook — Programmable Voice, not Messaging, so it is a separate callback URL from /hooks/twilio/inbound. The body is application/x-www-form-urlencoded (CallSid, From, To, CallStatus, Direction) and the RESPONSE IS TwiML (text/xml), not a 204: Twilio plays whatever this returns to the caller. The call becomes a conversation on the phone channel, keyed on the caller's From phone (thread key voice:<E.164>, deliberately distinct from SMS's sms:<E.164> so a call and a text from the same number never share a thread), with CallSid as the channel_message_id — Twilio redelivers the same CallSid until it gets a 2xx, so redelivery is a no-op.
RECORDING IS OFF UNLESS THE WORKSPACE OPTED IN — the feature.messaging.call_recording flag AND the settings.voice.recording_enabled workspace setting must BOTH be true. When they are, the returned TwiML always speaks a recording announcement to the caller before the <Record> verb; when they are not, the TwiML answers and hangs up and no audio is ever requested.
Authenticated by X-Twilio-Signature exactly as the Messaging hooks. Unlike them this route answers 200 with a refusal TwiML — never 404 — for an unknown, suspended, unactivated or un-entitled workspace: a 404 makes Twilio play its own "application error" to a live human, and one identical spoken refusal for every such case reveals nothing a 404 would not. 401 on a bad or missing signature (the caller is not Twilio, so there is nobody to speak to). Not a client API.
| workspaceId required | string |
| X-Twilio-Signature required | string |
| CallSid | string |
| From | string |
| To | string |
| CallStatus | string |
| Direction | string |
| property name* additional property | any |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Twilio's recordingStatusCallback, fired when a call recording is available: form-encoded CallSid, RecordingSid, RecordingUrl, RecordingDuration, RecordingStatus. Only ever reached when the workspace opted into recording, because only then does the answer webhook above emit a <Record> carrying this URL — and the consent gate is re-checked HERE anyway, so a callback that arrives after the opt-in was withdrawn stores and transcribes nothing.
A completed callback queues the ingest job, which downloads the audio into workspace-scoped media, transcribes it through the ATH-222 voice-to-text seam, and writes the transcript as a message on the call's conversation. RecordingSid is that message's channel_message_id, so a redelivered callback is a no-op rather than a second transcript.
Authenticated by X-Twilio-Signature exactly as the other Twilio hooks. 404 when the channel is not activated and 401 on a bad or missing signature — the SAME posture as the Messaging hooks, and correct here for the reason it is wrong on the answer webhook above: this peer is a machine throughout, so there is no caller on a line to speak a refusal to. 204 for everything it accepts AND for an activated workspace that has not opted into recording or sent nothing to ingest, both of which are real acceptances rather than errors Twilio could act on. Not a client API.
| workspaceId required | string |
| X-Twilio-Signature required | string |
| CallSid | string |
| RecordingSid | string |
| RecordingUrl | string |
| RecordingDuration | string |
| RecordingStatus | string |
| property name* additional property | any |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Requires inbox.configure. 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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "kind": "general",
- "routing_rules": [
- {
- "type": "channel",
- "value": "string"
}
]
}
]
}Requires inbox.configure. 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 | string <= 255 characters |
Array of objects <= 5 items |
{- "name": "string",
- "routing_rules": [
- {
- "type": "channel",
- "value": "string"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "kind": "general",
- "routing_rules": [
- {
- "type": "channel",
- "value": "string"
}
]
}
}Requires inbox.configure. 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 |
| inboxId 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 <= 255 characters |
Array of objects <= 5 items |
{- "name": "string",
- "routing_rules": [
- {
- "type": "channel",
- "value": "string"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "kind": "general",
- "routing_rules": [
- {
- "type": "channel",
- "value": "string"
}
]
}
}Requires inbox.configure. 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 |
| inboxId 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-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"
}
]
}
}ATH-204 (Doc 05 §5, acceptance scenario 4) — requires inbox.view and the feature.ai.tool_approvals flag. An approval task is created when a permission rule says a call is allowed but needs a human, and the tool has NOT run when the task appears here. Tasks carry the exact arguments the call was made with, frozen at request time. Scoped to the workspace in the path — another tenant's queue is never visible.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| status | string Enum: "pending" "approved" "denied" "expired" |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "status": "pending",
- "reason": "string",
- "arguments": { },
- "conversation_id": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-204 — requires inbox.view and the feature.ai.tool_approvals flag. Shows the frozen arguments, the rule that demanded approval, who asked, when it expires, and — once settled — who decided and what the execution returned.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| approvalId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "status": "pending",
- "reason": "string",
- "arguments": { },
- "conversation_id": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "tool_permission_rule_id": "string",
- "origin_type": "string",
- "origin_id": "string",
- "requested_by_user_id": "string",
- "decided_by_user_id": "string",
- "decided_at": "2019-08-24T14:15:22Z",
- "decision_note": "string",
- "executed_at": "2019-08-24T14:15:22Z",
- "execution_status": "string",
- "result": { }
}
}ATH-204 — requires inbox.reply and the feature.ai.tool_approvals flag. The tool runs asynchronously with the arguments FROZEN at request time; nothing in this request body can change what is executed. Approval is a single-shot state transition claimed with a conditional update, so a double-approve — two operators clicking at once, or a retried request — executes the tool exactly once and returns 409 approval_already_decided for the loser. An expired task cannot be approved.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| approvalId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| note | string or null <= 1000 characters Optional free-text rationale recorded with the decision. |
{- "note": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "status": "pending",
- "reason": "string",
- "arguments": { },
- "conversation_id": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "tool_permission_rule_id": "string",
- "origin_type": "string",
- "origin_id": "string",
- "requested_by_user_id": "string",
- "decided_by_user_id": "string",
- "decided_at": "2019-08-24T14:15:22Z",
- "decision_note": "string",
- "executed_at": "2019-08-24T14:15:22Z",
- "execution_status": "string",
- "result": { }
}
}ATH-204 — requires inbox.reply and the feature.ai.tool_approvals flag. The tool is never executed. Like approval this is a single-shot transition, so denying an already-decided task returns 409 approval_already_decided rather than overwriting the record. A workflow run parked on this task is woken and continues with a denied outcome.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| approvalId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| note | string or null <= 1000 characters Optional free-text rationale recorded with the decision. |
{- "note": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "status": "pending",
- "reason": "string",
- "arguments": { },
- "conversation_id": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "tool_permission_rule_id": "string",
- "origin_type": "string",
- "origin_id": "string",
- "requested_by_user_id": "string",
- "decided_by_user_id": "string",
- "decided_at": "2019-08-24T14:15:22Z",
- "decision_note": "string",
- "executed_at": "2019-08-24T14:15:22Z",
- "execution_status": "string",
- "result": { }
}
}Third-party integrations (Doc 06 §6) — the first is Slack (ATH-254), bi-directional per the catalog table: new customer messages are announced in a channel, and a staff reply in that Slack thread reaches the visitor.
NOT a channel. The omnichannel adapters under Inbox model a place CUSTOMERS talk to a workspace; Slack here is a place the workspace's own STAFF talk about a conversation that arrived somewhere else. That is why a Slack reply is written as an ordinary outbound message authored by a mapped Cairn user rather than as an inbound one, and why an unmapped Slack user cannot post at all.
The ONE callback URL for the Cairn Slack app, shared by every workspace — unlike the omnichannel receivers above, there is no workspace id in the path. Slack addresses one app, not one tenant, so the team_id in the body selects the installation and the workspace is derived from it. A team with no live installation is accepted and dropped (204), never 404: the response must not tell an unauthenticated caller which Slack teams are connected to Cairn.
AUTHENTICATION is Slack's request signature and nothing else ('hooks/*' authz exemption, no session, no key). v0= HMAC-SHA256 over the literal bytes v0:{X-Slack-Request-Timestamp}:{raw body} against the app's signing secret, compared with hash_equals. The timestamp is INSIDE the MAC, so a captured request cannot be re-stamped and still verify; it is additionally rejected outside a ±300s window. 300s is both Slack's own published recommendation and ATH-251's WebhookSignature::TOLERANCE_SECONDS, so the platform has ONE replay window rather than two.
The signing secret is APP-level (config), not per-workspace: it belongs to the Cairn Slack app we publish. Per-workspace bot tokens are a different secret and live in the encrypted vault.
Verification happens before the body is interpreted, so url_verification challenges are echoed only when signed. Real events are acknowledged immediately and processed on a queue — Slack retries anything it cannot get a response to within 3s, and a duplicate reply into a customer conversation is worse than a slow one. Not a client API.
| X-Slack-Signature required | string |
| X-Slack-Request-Timestamp required | string |
| type | string |
| team_id | string |
| challenge | string |
| property name* additional property | any |
{- "type": "string",
- "team_id": "string",
- "challenge": "string"
}{- "challenge": "string"
}ATH-256 — the ONE callback URL for the Cairn app on a platform, shared by every install, like ATH-254's Slack receiver and unlike the per-tenant omnichannel hooks: there is no workspace id in the path. Shopify signs on behalf of the app, so X-Shopify-Shop-Domain in the delivery selects the connection and the workspace is derived from it. A shop with no live connection is accepted and dropped (204), never 404 — the response must not tell an unauthenticated caller which stores are Cairn customers.
{platform} selects the adapter. WooCommerce sends no webhooks and has no receiver, so /hooks/ecommerce/woocommerce is 404.
AUTHENTICATION is the platform's request signature and nothing else ('hooks/*' authz exemption, no session, no key). X-Shopify-Hmac-Sha256 is base64(HMAC-SHA256(raw body, app secret)), compared with hash_equals over the VERBATIM body bytes. Unlike Slack, Shopify does NOT put a timestamp inside the MAC, so replay is bounded by DEDUP on X-Shopify-Webhook-Id (a redelivery is a no-op), not by a narrow time window: X-Shopify-Triggered-At is only an OUTER freshness bound (default 7 days, comfortably past Shopify's ~48h retry schedule) past which the dedup ledger can no longer be relied on. 401 on a bad, missing or stale signature.
WHAT IT DOES is lifecycle only. app/uninstalled and the mandatory shop/redact destroy the vault credential and revoke the row; customers/redact and customers/data_request are acknowledged and do nothing, because this integration stores no order or customer data to erase or export — GDPR compliance here is a genuine no-op. Order webhooks are not subscribed to. Not a client API.
| platform required | string Value: "shopify" The e-commerce platform this receiver serves. Only |
| X-Shopify-Hmac-Sha256 required | string |
| X-Shopify-Shop-Domain required | string |
| X-Shopify-Topic required | string |
| X-Shopify-Webhook-Id required | string |
| property name* additional property | any |
{ }{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-254 — requires integrations.manage. Returns data: null when Slack is not connected (a 200 with an empty answer, not a 404: "no connection" is the normal state of a settings screen, not a missing resource).
The bot token is NEVER included and there is no reveal endpoint. It is written to the encrypted vault by the OAuth callback and read only by the delivery job.
| 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",
- "team_id": "string",
- "team_name": "string",
- "bot_user_id": "string",
- "notify_channel_id": "string",
- "notify_channel_name": "string",
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-254 — requires integrations.manage. Setting notify_channel_id is what actually switches notifications on; a connected workspace with no channel chosen is connected but silent, which is the safe default for an integration that forwards customer messages into a chat room.
Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — and for the same reason: a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| notify_channel_id | string or null <= 32 characters Send null to go silent without disconnecting. |
| notify_channel_name | string or null <= 255 characters |
| status | string Value: "connected" The only accepted transition — re-enabling after an automatic disable. A workspace cannot disable itself this way; that is what clearing the channel is for. |
{- "notify_channel_id": "string",
- "notify_channel_name": "string",
- "status": "connected"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "team_id": "string",
- "team_name": "string",
- "bot_user_id": "string",
- "notify_channel_id": "string",
- "notify_channel_name": "string",
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-254 — requires integrations.manage. Three things happen and the order matters: the bot token is handed to Slack's auth.revoke so it stops working at THEIR end, it is deleted from our vault, and the installation row is stamped revoked_at.
The row is stamped rather than deleted (ATH-252's RevokeOAuthInstall discipline) so "when did we stop talking to Slack" survives the disconnect, and re-connecting later reuses it. A revoke call Slack refuses does NOT abort the disconnect — the local token is destroyed regardless, because a disconnect that fails halfway must not leave a usable token behind. Idempotent: disconnecting an already-disconnected workspace keeps the original timestamp.
| 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": { }
}
}ATH-254 — requires integrations.manage. Returns the slack.com/oauth/v2/authorize URL the admin's browser is sent to. Nothing is written; the installation row appears only when Slack calls back with a code.
state is an HMAC-signed, short-lived token binding the workspace AND the user who started the flow, so the callback can re-derive both without trusting a query parameter. The callback re-checks integrations.manage against live membership, so a state minted by an admin who was demoted mid-flow is refused.
422 when the Slack app is not configured on this deployment — the client credentials are ours, not the workspace's, and a connect button that leads to a Slack error page is worse than one that explains itself.
| 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 |
{
}ATH-254 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted post to Slack, carrying the outcome, the attempt count and — the field that matters when debugging — slack_error, the error string Slack returned in a 200 OK body.
Slack signals failure with HTTP 200 and {"ok": false}, so a transport-only log would show this integration as permanently healthy while every message was being rejected. Doc 06 §6 asks for per-integration health and logs; this is that surface.
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "pending" "succeeded" "failed" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "channel_id": "string",
- "thread_ts": "string",
- "status": "pending",
- "attempt_count": 0,
- "max_attempts": 0,
- "slack_error": "string",
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-255 — requires integrations.manage. Doc 06 §6 names four CRMs; this returns the ones actually implemented, which is two (HubSpot, Pipedrive). Salesforce and Zoho are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works.
supports_refresh tells the UI whether to expect an OAuth redirect or an API-token field, and is the axis the adapter seam was designed against.
| 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": [
- {
- "key": "hubspot",
- "label": "string",
- "supports_refresh": true,
- "default_field_map": {
- "property1": "string",
- "property2": "string"
}
}
]
}ATH-255 — requires integrations.manage. One row per connected provider; a workspace may sync to more than one at a time.
Access and refresh tokens are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the sync job.
| 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",
- "provider": "hubspot",
- "account_label": "string",
- "remote_account_id": "string",
- "direction": "push",
- "conflict_strategy": "crm_wins",
- "field_map": {
- "property1": "string",
- "property2": "string"
}, - "sync_unsubscribed": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_synced_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
]
}ATH-255 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; crm_connections has no token column.
Reconnecting an existing provider REUSES the row rather than creating a second one, which is what preserves the external-id mappings in crm_contact_links — and therefore the idempotency. A fresh connection would orphan every mapping and the next sync would duplicate the workspace's entire contact book in the CRM.
direction defaults to push: connecting a CRM must not rewrite a workspace's existing contacts on the strength of a checkbox. conflict_strategy is only ever consulted under bidirectional, and only for a genuine three-way conflict — see the CrmSyncRun conflicts field.
| 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 |
| provider required | string Enum: "hubspot" "pipedrive" |
| access_token required | string <= 500 characters Written to the encrypted vault and to nowhere else. Never returned by any endpoint; there is no reveal. |
| refresh_token | string or null <= 500 characters OAuth providers only. Without one, an expiring access token has no path back to health and the connection will land in |
| expires_in | integer or null >= 1 Seconds until the access token expires. |
| account_label | string or null <= 255 characters |
| remote_account_id | string or null <= 64 characters |
| direction | string Enum: "push" "pull" "bidirectional" |
| conflict_strategy | string Enum: "crm_wins" "cairn_wins" |
| sync_unsubscribed | boolean |
object |
{- "provider": "hubspot",
- "access_token": "string",
- "refresh_token": "string",
- "expires_in": 1,
- "account_label": "string",
- "remote_account_id": "string",
- "direction": "push",
- "conflict_strategy": "crm_wins",
- "sync_unsubscribed": true,
- "field_map": {
- "property1": "string",
- "property2": "string"
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "provider": "hubspot",
- "account_label": "string",
- "remote_account_id": "string",
- "direction": "push",
- "conflict_strategy": "crm_wins",
- "field_map": {
- "property1": "string",
- "property2": "string"
}, - "sync_unsubscribed": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_synced_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-255 — requires integrations.manage. field_map is the PII allowlist: a workspace narrows what leaves the building, and keys outside the syncable set are dropped rather than accepted.
Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead. A connection in needs_reauth cannot be re-enabled this way: only re-authorising fixes a revoked grant, so pretending otherwise would just re-break it on the next sync.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| direction | string Enum: "push" "pull" "bidirectional" |
| conflict_strategy | string Enum: "crm_wins" "cairn_wins" |
| sync_unsubscribed | boolean |
object | |
| account_label | string or null <= 255 characters |
| status | string Value: "connected" The only accepted transition — re-enabling after an automatic disable. A connection in |
{- "direction": "push",
- "conflict_strategy": "crm_wins",
- "sync_unsubscribed": true,
- "field_map": {
- "property1": "string",
- "property2": "string"
}, - "account_label": "string",
- "status": "connected"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "provider": "hubspot",
- "account_label": "string",
- "remote_account_id": "string",
- "direction": "push",
- "conflict_strategy": "crm_wins",
- "field_map": {
- "property1": "string",
- "property2": "string"
}, - "sync_unsubscribed": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_synced_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-255 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can call that customer's CRM, because the token is gone rather than merely unreferenced.
The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), and the contact links are KEPT. Discarding them would mean a workspace that reconnects re-creates every contact it already has in the CRM.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-255 — requires integrations.manage. With contact_id, queues one contact; without, queues a backfill of every contact in the workspace.
THIS IS EXPLICIT BECAUSE IT HAS TO BE. There is no contact.created domain event in packages/core/src/Events (only contact.unsubscribed), so there is nothing for a continuous sync to subscribe to — the same gap that left ATH-253's Zapier "New Contact" trigger unbuilt, tracked on PA-254. Emitting it means adding a dispatch to the contacts domain, which CLAUDE.md rule 6 reserves for that squad rather than for an integration ticket. Sync-on-contact-change is therefore NOT shipped; when the event lands, a listener calling the same action is the whole remaining change.
Idempotent in two layers: a contact with a run already pending is not queued twice, and crm_contact_links makes even a genuinely duplicated run converge on the same CRM record rather than creating a second one.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| contact_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Omit to queue a backfill of every contact in the workspace. |
{- "contact_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}{- "data": {
- "queued": 0,
- "sync_run_id": "string"
}
}ATH-255 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted sync of one contact.
Two fields carry the information a status code cannot. provider_error is the vendor's own code — Pipedrive reports failure as HTTP 200 with {"success": false}, so a transport-only log would show this integration as permanently healthy while nothing synced. conflicts records BOTH values of every field where the two systems genuinely disagreed, so the value that lost the merge is discarded rather than lost.
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "pending" "succeeded" "failed" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "connection_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "contact_id": "string",
- "status": "pending",
- "outcome": "created",
- "remote_id": "string",
- "attempt_count": 0,
- "max_attempts": 0,
- "conflicts": [
- {
- "field": "string",
- "cairn": "string",
- "crm": "string",
- "base": "string",
- "winner": "cairn"
}
], - "provider_error": "string",
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-257 — requires integrations.manage. Streams Cairn's domain events OUT to the workspace's Segment/CDP as track/identify calls, the mirror image of the outbound webhooks pointed at one fixed vendor endpoint. A workspace has exactly ONE source, so this is a singular resource with no id in the path; data is null when none is configured.
The Segment write key is NEVER included and there is no reveal endpoint. It is written to the encrypted vault and read only by the delivery job.
| 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",
- "region": "us",
- "label": "string",
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-257 — requires integrations.manage. The write key in the request body is written to the encrypted vault and to nowhere else; segment_source_connections has no key column.
Reconnecting REUSES the row rather than creating a second one — a workspace has one source, and reusing the row keeps its delivery history across a re-key. region picks the Segment Tracking API host (us = api.segment.io, eu = events.eu1.segmentapis.com); a write key is minted for one region, and streaming EU-resident data to the US host is a residency violation, not a routing detail.
| 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 |
| write_key required | string <= 255 characters The Segment source write key. Written to the encrypted vault and to nowhere else; never returned by any endpoint, and there is no reveal. Sent to Segment as HTTP Basic auth (base64(write_key + ":")), never in a query string or a log. |
| region | string Enum: "us" "eu" Defaults to |
| label | string or null <= 255 characters |
{- "write_key": "string",
- "region": "us",
- "label": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "region": "us",
- "label": "string",
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-257 — requires integrations.manage. Re-enabling after an automatic disable clears the failure counter and recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken source rather than have a retry quietly resume one we have evidence is dead (most often a write key the customer rotated in Segment, which is fixed by re-connecting with a new key rather than by re-enabling).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| region | string Enum: "us" "eu" |
| label | string or null <= 255 characters |
| status | string Value: "connected" The only accepted transition — re-enabling after an automatic disable, which clears the failure counter and reason. A source disabled for a rejected write key should be RE-CONNECTED with a new key instead; re-enabling alone would re-break it on the next delivery. |
{- "region": "us",
- "label": "string",
- "status": "connected"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "region": "us",
- "label": "string",
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-257 — requires integrations.manage. The vault write key is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can stream another event to that customer's Segment workspace, because the key is gone rather than merely unreferenced. The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), so a reconnect reuses it.
| 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": { }
}
}ATH-257 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per domain event streamed to Segment, with its own retry lifecycle. message_id is the Segment messageId — stable across retries, so Segment dedupes on it and a retried delivery is not double-counted in the warehouse.
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "pending" "succeeded" "failed" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "connection_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "message_id": "string",
- "event_name": "string",
- "call_type": "track",
- "status": "pending",
- "attempt_count": 0,
- "max_attempts": 0,
- "last_status_code": 0,
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-245 — requires integrations.manage. Doc 06 §6 names four external tools to push OUT to; this returns the ones actually implemented, which is two (Confluence, Zendesk Guide). Notion and SharePoint are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works, and Notion in particular is a block-tree content model whose faithful converter is a ticket of its own, not a thin follow-up.
| 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": [
- {
- "key": "confluence",
- "label": "string"
}
]
}ATH-245 — requires integrations.manage. One row per connected target; a workspace may push to more than one at a time.
The account email and API token are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the push job.
| 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",
- "target": "confluence",
- "account_label": "string",
- "base_url": "string",
- "remote_container": "string",
- "target_settings": {
- "permission_group_id": 1,
- "user_segment_id": 1
}, - "auto_push": true,
- "auto_retract": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_pushed_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
]
}ATH-245 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; content_push_connections has no token column.
Reconnecting an existing target REUSES the row rather than creating a second one, which is what preserves the external-id mappings in content_push_links — and therefore the idempotency. A fresh connection would orphan every mapping and the next push would publish a second copy of every article into the customer's wiki.
base_url is the customer's OWN Confluence/Zendesk host. It is validated at connect time against the outbound guard (an internal or single-label host is refused with 422) and re-checked on every push, because a public hostname can resolve to a private address later. auto_push defaults false: replicating published content into a third-party system on every publish is an opt-in choice.
So does auto_retract (PA-269), and for the mirror-image reason: reaching back into a customer's own Confluence or Zendesk to unpublish a page is an action on data we do not own. Off, a retraction in Cairn leaves the remote copy exactly as it is.
| 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 |
| target required | string Enum: "confluence" "zendesk" |
| base_url required | string <= 500 characters The customer's Confluence wiki base ( |
| email required | string <= 255 characters The account email of the API token. Written to the encrypted vault with the token and to nowhere else; never returned. |
| api_token required | string <= 500 characters The Confluence/Zendesk API token. Written to the encrypted vault and to nowhere else. Never returned by any endpoint; there is no reveal. |
| remote_container | string or null <= 128 characters The Confluence space key or Zendesk section id to create pages in. |
| account_label | string or null <= 255 characters |
| auto_push | boolean |
| auto_retract | boolean |
{- "target": "confluence",
- "base_url": "string",
- "email": "string",
- "api_token": "string",
- "remote_container": "string",
- "account_label": "string",
- "auto_push": true,
- "auto_retract": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "target": "confluence",
- "account_label": "string",
- "base_url": "string",
- "remote_container": "string",
- "target_settings": {
- "permission_group_id": 1,
- "user_segment_id": 1
}, - "auto_push": true,
- "auto_retract": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_pushed_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-245 — requires integrations.manage. Re-enabling after an automatic disable clears the failure counter and the recorded reason, exactly as ATH-251's endpoint PATCH does — a workspace must acknowledge a broken connection rather than have a retry quietly resume one we have evidence is dead. A connection in needs_reauth cannot be re-enabled this way: the target rejected the credential, so only reconnecting fixes it.
PA-273 — this is also where a Zendesk connection's access controls are set (target_settings.permission_group_id / user_segment_id). It extends this endpoint rather than adding a second one, following PA-266: these are connection configuration, and configuration of one connection belongs on the one PATCH that configures it.
target_settings is DEEP-MERGED into the stored bag, so sending one key never deletes its siblings (PA-270's regression, which this codebase has already fixed twice). An explicit null clears a key, and clearing means Cairn stops sending the field at all — it does NOT send a null to Zendesk, which would read as "visible to everyone" for user_segment_id. Keys the connection's target does not understand are refused with 422 rather than stored: a Confluence connection accepts no target_settings keys at all today.
The ids are Zendesk's own numeric ids, which a Guide admin reads out of the Zendesk admin UI. Cairn does not list them for you: an id-picker would have to read the vault credential from a web request (today only the push job does) and make a synchronous, paginated call to the customer's Zendesk from inside one (rule 5 says queue anything external). Both are real design changes, so the picker is a separate ticket and this endpoint takes the ids directly.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| auto_push | boolean |
| auto_retract | boolean |
| account_label | string or null <= 255 characters |
| remote_container | string or null <= 128 characters |
object (ContentPushTargetSettings) PA-324 — deliberately has NO PA-273 — the settings that only ONE target understands, kept off the shared columns that every target has to share a meaning for.
The bag is CLOSED ( WRITES ARE A DEEP MERGE, NOT A REPLACE (PA-270/PA-271's | |
| status | string Value: "connected" The only accepted transition — re-enabling after an automatic disable. A connection in |
{- "auto_push": true,
- "auto_retract": true,
- "account_label": "string",
- "remote_container": "string",
- "target_settings": {
- "permission_group_id": 1,
- "user_segment_id": 1
}, - "status": "connected"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "target": "confluence",
- "account_label": "string",
- "base_url": "string",
- "remote_container": "string",
- "target_settings": {
- "permission_group_id": 1,
- "user_segment_id": 1
}, - "auto_push": true,
- "auto_retract": true,
- "status": "connected",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_pushed_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-245 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no job, replay or operator can call that customer's Confluence or Zendesk, because the token is gone rather than merely unreferenced.
The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline), and the push links are KEPT. Discarding them would mean a workspace that reconnects publishes a second copy of every article it already has over there. Content already pushed is NOT removed from the remote — it is a copy under the customer's own governance, and disconnecting stops the writes rather than retracting them.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-245 — requires integrations.manage. With article_id, pushes one PUBLISHED article; without, backfills every published article in the workspace. A draft is refused (422): only published content leaves.
Automatic on-publish push exists too and is NOT this endpoint: a listener on the existing content.published event (ATH-151) queues a push for every connection with auto_push on, for PUBLIC articles only. This endpoint is the manual "push now" / backfill, and — unlike the auto path — an operator invoking it may push a non-public published article, because that is an explicit human choice rather than an automation acting on content somebody kept off the open help centre.
Idempotent in two layers: an article with a run already pending is not queued twice, and content_push_links makes even a genuinely duplicated run UPDATE the same remote page rather than publishing a second copy.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| article_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Omit to backfill every published article in the workspace. |
{- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}{- "data": {
- "queued": 0,
- "push_run_id": "string"
}
}ATH-245 — requires integrations.manage. Newest-first on the ULID primary key (never on a nullable timestamp). One row per attempted push of one article.
Two fields carry what a status code cannot. provider_error is the target's own error code — Confluence answers a stale page version with 409, Zendesk wraps field errors in a 422 details object. conversion_notes records what the HTML→target conversion could not carry faithfully (a relative image URL that will not resolve remotely, an element with no target representation), so a lossy push is visible in the log rather than only in a diff nobody runs.
| workspaceId 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 .. 100 ] Default: 25 |
| status | string Enum: "pending" "succeeded" "failed" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "connection_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "article_id": "string",
- "article_ref": "string",
- "operation": "push",
- "status": "pending",
- "outcome": "created",
- "remote_id": "string",
- "remote_url": "string",
- "attempt_count": 0,
- "max_attempts": 0,
- "conversion_notes": [
- "string"
], - "provider_error": "string",
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-256 — requires integrations.manage. Doc 06 §6 names four platforms; this returns the ones actually implemented, which is two (Shopify, WooCommerce). Adobe Commerce and PrestaShop are deliberately absent rather than listed-and-broken: a name in this list is a promise that connecting it works.
supports_oauth tells the connect screen whether to collect a shop domain and an access token (Shopify) or a site URL and a consumer key/secret (WooCommerce) — the axis the adapter seam was designed against. handles_inbound_webhooks is false for WooCommerce, which sends none (see the schema).
| 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": [
- {
- "key": "shopify",
- "label": "string",
- "supports_oauth": true,
- "handles_inbound_webhooks": true
}
]
}ATH-256 — requires integrations.manage. One row per connected store; a workspace may connect several at once, across platforms or even two on one platform.
Access tokens and consumer secrets are NEVER included and there is no reveal endpoint. They are written to the encrypted vault and read only by the fetch path.
| 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",
- "platform": "shopify",
- "store_domain": "string",
- "store_url": "string",
- "store_label": "string",
- "remote_account_id": "string",
- "status": "connected",
- "disabled_reason": "string",
- "last_context_fetched_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
]
}ATH-256 — requires integrations.manage. The credential in the request body is written to the encrypted vault and to nowhere else; ecommerce_connections has no token column.
The body shape depends on platform. Shopify wants store_domain (acme.myshopify.com) and access_token; WooCommerce wants store_url (an https:// site URL) and a consumer_key / consumer_secret pair. A wrong-shaped body is a 422 naming the missing field.
Reconnecting the same store REUSES its row rather than creating a second one, which keeps the webhook-dedup ledger's foreign key intact so an app/uninstalled webhook arriving twice during a reconnect is still processed once.
| 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 |
| platform required | string Enum: "shopify" "woocommerce" |
| store_label | string or null <= 255 characters |
| store_domain | string <= 255 characters Shopify only — |
| access_token | string <= 500 characters Shopify only. Written to the vault; never returned. |
| store_url | string <= 512 characters WooCommerce only — an https:// site URL. http is refused. |
| consumer_key | string <= 255 characters WooCommerce only. Written to the vault; never returned. |
| consumer_secret | string <= 255 characters WooCommerce only. Written to the vault; never returned. |
{- "platform": "shopify",
- "store_label": "string",
- "store_domain": "string",
- "access_token": "string",
- "store_url": "string",
- "consumer_key": "string",
- "consumer_secret": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "platform": "shopify",
- "store_domain": "string",
- "store_url": "string",
- "store_label": "string",
- "remote_account_id": "string",
- "status": "connected",
- "disabled_reason": "string",
- "last_context_fetched_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-256 — requires integrations.manage. disabled is an admin pause, never an automatic state: the read is synchronous, so a store outage costs a workspace some grey panels and nothing else, and auto-disabling a good connection over it would need a human to undo. A connection in needs_reauth cannot be resumed this way — only reconnecting with a fresh credential fixes a rejected one, so pretending otherwise would just re-break it on the next read.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| store_label | string or null <= 255 characters |
| status | string Enum: "connected" "disabled"
|
{- "store_label": "string",
- "status": "connected"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "platform": "shopify",
- "store_domain": "string",
- "store_url": "string",
- "store_label": "string",
- "remote_account_id": "string",
- "status": "connected",
- "disabled_reason": "string",
- "last_context_fetched_at": "2019-08-24T14:15:22Z",
- "connected_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
}ATH-256 — requires integrations.manage. The vault credential is destroyed, which is the part that is immediate and total: afterwards no request, replay or operator can call that merchant's store, because the token is gone rather than merely unreferenced.
The connection row is stamped revoked_at rather than deleted (ATH-252's RevokeOAuthInstall discipline). There is nothing else to clean up — this integration stored no order data to purge, which is the fetch-on-demand decision paying out.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| connectionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-256 — requires inbox.view, one tier below the management surface, because the reader is a support agent mid-conversation and this is a card in ATH-135's context sidebar. Fetched LIVE from each store on demand; nothing is synced or stored (see the EcommerceStoreContext schema on the state field and the data-residency reasoning).
Keyed by email — the join key Cairn and a storefront reliably share — rather than by conversation id, so the integration does not reach into the conversations domain to resolve a contact. Every connected store is asked and every answer is returned, including a store that is throttled or down, so an agent never mistakes "we could not ask this store" for "this customer has no orders here".
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| email required | string <email> <= 255 characters |
| connection_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: connection_id=01j8me9fycv0q4t4c7wz8k2xt1 Scope the lookup to a single store instead of all of them. |
{- "data": [
- {
- "connection_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "platform": "shopify",
- "store_domain": "string",
- "store_label": "string",
- "state": "ok",
- "customer": {
- "id": "string",
- "name": "string",
- "email": "string",
- "orders_count": 0,
- "total_spent": "string",
- "currency": "string"
}, - "orders": [
- {
- "id": "string",
- "number": "string",
- "financial_status": "string",
- "fulfillment_status": "string",
- "total": "string",
- "currency": "string",
- "placed_at": "2019-08-24T14:15:22Z",
- "item_count": 0,
- "status_url": "string"
}
]
}
]
}The public widget surface (Doc 04 §2). These endpoints carry no session: a widget key or signed visitor token names the tenant.
ATH-117 — the embed snippet's ws_pub key is the only credential; it can mint anonymous visitor sessions and nothing else. The bearer token is returned exactly once and lives in the widget's localStorage. Suspended workspaces don't serve.
| website_id required | string <= 40 characters |
{- "website_id": "string"
}{- "data": {
- "visitor_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "token": "string",
- "workspace": {
- "name": "string"
}
}
}ATH-117 — find-or-create by (workspace, email), link the visitor, and stamp the contact onto the visitor's existing conversations so history follows the person. ATH-118 — pass hmac = HMAC-SHA256(lowercased email, widget_identity_secret), signed by YOUR backend, to verify the identity (Crisp-style anti-impersonation). An invalid or unconfigured signature is 403; claiming an already-verified identity WITHOUT a signature is 403; verification only ever ratchets up.
| Idempotency-Key | string <= 255 characters |
| email required | string <email> <= 255 characters |
| name | string or null <= 255 characters |
| hmac | string or null <= 64 characters HMAC-SHA256(email, widget_identity_secret) hex |
{- "email": "user@example.com",
- "name": "string",
- "hmac": "string"
}{- "data": {
- "contact_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "email": "string",
- "verified": true
}
}ATH-120 — cached 60s per workspace; online is computed from working hours per request (up to 60s staleness on the cached spec is accepted). white_label is plan-gated and re-checked at config build, so downgrades revoke within the TTL.
| website_id required | string <= 40 characters |
{- "data": {
- "workspace_name": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "font": "string"
}, - "position": "left",
- "launcher_color": "string",
- "greeting": "string",
- "online": true,
- "offline_form": true,
- "working_hours": { },
- "white_label": true,
- "calls_enabled": true,
- "triggers": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conditions": [
- { }
], - "message": "string"
}
], - "struggle_detection": true,
- "nudges": {
- "sensitivity": "low",
- "page_blocklist": [
- "string"
]
}
}
}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": {
- "position": "left",
- "launcher_color": "string",
- "greeting": "string",
- "working_hours": { },
- "offline_form": true,
- "struggle_detection_enabled": true,
- "nudge_sensitivity": "low",
- "nudge_page_blocklist": [
- "string"
]
}
}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 |
| position required | string Enum: "left" "right" |
| launcher_color required | string or null #rrggbb; null = brand primary |
| greeting required | string or null <= 200 characters |
| working_hours required | object or null {timezone, days: {mon: [start, end], …}}; missing day = closed; null = always online |
| offline_form required | boolean |
| struggle_detection_enabled required | boolean PA-229 (Doc 07 §4) — the real per-workspace enablement PA-227 deferred: when true (and |
| nudge_sensitivity required | string Enum: "low" "medium" "high" PA-229 — the tuning slider. Maps client-side to which struggle kinds may nudge and the frequency-cap cooldowns (low = fewer, quieter nudges; high = more, sooner). Default |
| nudge_page_blocklist required | Array of strings <= 200 items [ items <= 2048 characters ] PA-229 — URL patterns (the same |
{- "position": "left",
- "launcher_color": "string",
- "greeting": "string",
- "working_hours": { },
- "offline_form": true,
- "struggle_detection_enabled": true,
- "nudge_sensitivity": "low",
- "nudge_page_blocklist": [
- "string"
]
}{- "data": {
- "position": "left",
- "launcher_color": "string",
- "greeting": "string",
- "working_hours": { },
- "offline_form": true,
- "struggle_detection_enabled": true,
- "nudge_sensitivity": "low",
- "nudge_page_blocklist": [
- "string"
]
}
}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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "enabled": true,
- "conditions": [
- {
- "type": "url_matches",
- "value": null
}
], - "message": "string"
}
]
}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 |
| Idempotency-Key | string <= 255 characters |
| name | string <= 255 characters |
| enabled | boolean |
| message | string <= 500 characters |
Array of objects [ 1 .. 5 ] items |
{- "name": "string",
- "enabled": true,
- "message": "string",
- "conditions": [
- {
- "type": "url_matches",
- "value": null
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "enabled": true,
- "conditions": [
- {
- "type": "url_matches",
- "value": null
}
], - "message": "string"
}
}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 |
| triggerId 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 <= 255 characters |
| enabled | boolean |
| message | string <= 500 characters |
Array of objects [ 1 .. 5 ] items |
{- "name": "string",
- "enabled": true,
- "message": "string",
- "conditions": [
- {
- "type": "url_matches",
- "value": null
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "enabled": true,
- "conditions": [
- {
- "type": "url_matches",
- "value": null
}
], - "message": "string"
}
}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 |
| triggerId 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-123 — the first ping stamps widget_installed_at; later pings are no-ops. Keyed by the public website id, throttled.
| Idempotency-Key | string <= 255 characters |
| website_id required | string <= 40 characters |
{- "website_id": "string"
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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"
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| q required | string [ 2 .. 200 ] characters |
{- "data": [
- {
- "slug": "string",
- "title": "string",
- "excerpt": "string"
}
]
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| slug required | string |
{- "data": {
- "slug": "string",
- "title": "string",
- "body": "string"
}
}ATH-270 (Doc 07 §2.1) — the payload the walkthrough runtime replays: steps with structured element data and signed media URLs, same shape as getGuide. Published guides only, pinned to the visitor's workspace. No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| 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": 1,
- "y": 1,
- "w": 1,
- "h": 1
}, - "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"
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string"
}
}PA-229 (Doc 07 §4) — the visitor-token read the client nudge engine queries on a struggle signal: the PUBLISHED guides relevant to a page URL, ranked pinned-first. It is the widget-facing projection of the PA-228 page↔knowledge map (PageKnowledgeMap), reusing the SAME tolerant urlMatches rule so client and server agree on relevance — the client never re-derives guide relevance. Only guide_id + title are returned (the toast label + the walkthrough target); the matched STEP is resolved client-side from the guide the walkthrough player already fetches, so step indices never diverge. Draft/unpublished guides are omitted (a nudge must never open a guide the walkthrough endpoint would 404). The bearer visitor token names the tenant, so the result is pinned to the visitor's workspace (rule 2). Dark below feature.guidance.nudges — 404, so an off workspace leaks nothing.
| url required | string <= 2048 characters The visitor's current page URL to resolve relevant guides for. |
{- "data": [
- {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string"
}
]
}PA-229 (Doc 07 §4) — the client reports each step of a nudge's lifecycle here so the tuning dashboard can measure the funnel (shown → helped → conversation avoided). The bearer visitor token names the tenant; a nudge_events row is written under it.
A DEDICATED visitor-token endpoint rather than the beacon collector, for two reasons: it is idempotent on the natural (nudge_id, phase) key — a phase is reported at most once per nudge, so a retry (or the Idempotency-Key replay) is a no-op, which the beacon's fire-and-forget batch cannot guarantee — and the visitor session authenticates the workspace without trusting a client-supplied website key.
PRIVACY (rule 3, PA-227's stance): a nudge event carries INTERACTION METADATA ONLY — the nudge/guide ids, the struggle kind, the page (stored as origin+path, query and hash dropped), and the lifecycle phase. It never carries anything the visitor typed or that any element showed. Dark below feature.guidance.nudges (404).
| Idempotency-Key | string <= 255 characters |
| nudge_id required | string <= 64 characters Client-generated id grouping one nudge's whole lifecycle. |
| phase required | string Enum: "shown" "dismissed" "clicked" "walkthrough_started" "helped" "conversation_started" Where in the funnel this report sits. |
Ulid (string) or null The matched guide the nudge pointed at. | |
| kind | string or null <= 32 characters The struggle kind that triggered the nudge (rage_click, thrash, …). |
| url | string or null <= 2048 characters The page the nudge fired on; stored as origin+path only. |
{- "nudge_id": "string",
- "phase": "shown",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "kind": "string",
- "url": "string"
}{- "data": {
- "recorded": true
}
}ATH-212 (Doc 04 §6) — an in-app campaign has no push channel, so it is delivered when the audience member is next PRESENT in the widget. This endpoint is that presence signal: the widget calls it once per boot (and again after user.set identifies the visitor), and the server decides — from the campaign's segment audience, which never leaves the server — whether this contact has an undelivered in-app campaign waiting.
When one does, the message is written into the visitor's widget conversation like any other message and returned here so the widget can surface it proactively. null is the ordinary answer and means nothing is waiting: no audience match, an anonymous visitor, a suppressed contact, a frequency cap, or the feature.messaging.in_app_campaigns flag being dark for the workspace. The widget must treat all of those identically.
Delivery is claimed per (campaign, contact) before the message is written, so calling this repeatedly — across reconnects, tabs, devices, or a fresh browser — yields the campaign exactly once. A second call returns null, not the same message again.
{- "data": {
- "campaign_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "message_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "body": "string"
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "pending",
- "subject": "string",
- "last_message_at": "2019-08-24T14:15:22Z"
}
]
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "state": "string",
- "channel": "string"
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| conversationId 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",
- "direction": "in",
- "author_type": "contact",
- "kind": "text",
- "body": "string",
- "payload": { },
- "answer": {
- "type": "text",
- "sources": [
], - "execution": {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "prefill": [
- {
- "step_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "value": "string"
}
], - "needs_manual": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
]
}
}, - "translation": {
- "body": "string",
- "target_locale": "string",
- "source_locale": "string",
- "audience": "agent",
- "confidence": 0,
- "prompt_version": "string",
- "credit_cost_cents": 0
}, - "read_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-116 — writes through the shared SendMessage path: reopens a resolved conversation, publishes message.created (conversation channel) + conversation.updated (workspace inbox) as durable fabric events. Attachments land with the AV pipeline (flagged).
| conversationId 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 |
| body required | string <= 10000 characters |
{- "body": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z"
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| conversationId 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 |
| preview | string or null <= 200 characters |
{- "preview": "string"
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}No session: the credential in the request (widget key, invitation token, or webhook signature) names the tenant and is verified server-side.
| conversationId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-134 — only resolved conversations accept ratings; re-rating updates (the customer's last word wins). Agents receive conversation.updated with the score.
| conversationId 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 |
| rating required | integer [ 1 .. 5 ] |
{- "rating": 1
}{- "data": {
- "rating": 0
}
}ATH-261 (Doc 04 §9) — the visitor's EXPLICIT opt-in. Co-browse never starts silently: only after this call does the session go active, and only then will the frames endpoint relay anything. The token in the request scopes the session to this visitor's own conversation; another visitor's session id 404s. A session that is no longer pending (already ended/expired/declined) 409s.
| sessionId 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": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "channel": "string",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-261 — the visitor says no. The session terminates as declined and can never stream; the agent is told via cobrowse.ended. Refusing is always available and always final.
| sessionId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-261 — the visitor's browser posts a batch of rrweb events; the server relays each as an EPHEMERAL cobrowse.frame on the conversation channel and stores NONE of it. Rejected with 409 unless the session is active (consent given) and not expired — a pre-consent or post-expiry stream carries no frames. Frames are already masked client-side (password + [data-private]); the server never sees raw sensitive values.
| sessionId 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 |
required | Array of objects <= 500 items rrweb event objects, opaque to the server and relayed verbatim. |
{- "events": [
- { }
]
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-261 — the visitor ends the session at any time. Idempotent; publishes cobrowse.ended. Either party ending is final.
| sessionId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-261 (Doc 07 §3, Option B) — the visitor's DISTINCT, explicit guide-capture opt-in, separate from the co-browse view consent. Only after this call may the recorder ingest steps. The bearer token scopes the request to this visitor's own co-browse session; another visitor's session id 404s. A request that is no longer pending (already consented, or revoked) 409s.
| sessionId 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": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "cobrowse_session_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "requested",
- "requested_at": "2019-08-24T14:15:22Z",
- "consented_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "capture_session_id": "01j8me9fycv0q4t4c7wz8k2xt1"
}
}PA-261 — the visitor stops the guide recording at any time. Idempotent and always available (never flag-gated), so consent can always be withdrawn: every subsequent start/append is refused at once. The live co-browse VIEW is unaffected — this revokes only the guide recording.
| sessionId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-261 — the recorder opens a CaptureSession (source=cobrowse, no user) for the visitor's masked, structured steps. Fails CLOSED on all three gates: the co-browse session must be active, the agent must have initiated guide capture, and the visitor must have consented and not revoked — missing any is refused (403). Idempotent: the record binds to exactly one capture session, so a repeat returns the same one. 404 below the feature flag.
| sessionId 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 |
object (AppContext) |
{
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "source": "extension",
- "status": "recording",
- "started_at": "2019-08-24T14:15:22Z"
}
}PA-261 — the recorder appends masked, structured steps (labels only, rule 3; selector_candidates required, rule 4), idempotent by (session, seq). Fails CLOSED on the same three gates as start, re-checked on EVERY batch — so revoking consent stops ingest at once. A value-bearing step (raw typed text) is rejected 422 and never stored.
| sessionId 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 |
required | Array of objects (CaptureStep) <= 50 items |
{- "steps": [
- {
- "seq": 1,
- "type": "click",
- "ts": 0,
- "screenshot_ref": "string",
- "click": {
- "x": 0,
- "y": 0
}, - "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"
}, - "input": {
- "masked": true,
- "label": "string"
}, - "voice_transcript_span": [
- 0,
- 0
]
}
]
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-261 — closes the capture session and hands it to the SAME AssembleGuide pipeline any authored capture flows through, yielding an editable draft guide in the agent's authoring flow. Refused (403) if consent was revoked. Idempotent — re-finalizing returns the same guide. A workspace at its plan's guide ceiling gets 402.
| sessionId 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": {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "assembling"
}
}ATH-262 (Doc 04 §9's widget "call" button) — the OTHER direction. The visitor pressing call on their own device, with their own token, IS the consent, so the session is created already active with consented_at set and the visitor may mint a token at once. An agent still needs inbox.reply to mint theirs.
The conversation must be the caller's own widget conversation; any other id 404s. Flag-gated (feature.messaging.av_calls) and throttled.
| Idempotency-Key | string <= 255 characters |
| conversation_id required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| media | string Default: "audio" Enum: "audio" "video" |
{- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "media": "audio"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "media": "audio",
- "initiated_by": "agent",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-262 — the visitor's EXPLICIT opt-in, and the ONLY transition that makes a call joinable. Until it happens no token is minted for anybody, so an agent cannot open a visitor's microphone or camera by asking. It can only be driven by the visitor's own token; another visitor's call id 404s.
media may NARROW the request but never widen it: a video request may be answered audio (mic only, and the agent's token is narrowed too), while answering an audio request with video is 422. A call that is no longer pending 409s.
| callId 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 |
| media | string Enum: "audio" "video" What the visitor is agreeing to. Omit to accept as asked. |
{- "media": "audio"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "conversation_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "pending",
- "media": "audio",
- "initiated_by": "agent",
- "consented_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "ended_reason": "string"
}
}ATH-262 — the visitor says no. The call terminates as declined and can never be joined; the agent is told via call.ended. Refusing is always available and always final.
| callId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-262 — the visitor's join credential, scoped to their own identity in this call's room and to nothing else. Refused with 409 unless the call is joinable (active, consented, unexpired); the call id is pinned to this visitor's own calls, so another visitor's or another tenant's call 404s. Re-request on reconnect — each issue re-checks that consent still stands.
| callId 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": {
- "token": "string",
- "url": "string",
- "room": "string",
- "identity": "string",
- "media": "audio",
- "expires_at": "2019-08-24T14:15:22Z"
}
}ATH-262 — the visitor ends the call at any time. Idempotent; publishes call.ended and asks LiveKit to delete the room. Either party hanging up is final.
| callId 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 |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-263 (Doc 04 §2.3) — the mobile SDK registers its APNs (ios) or FCM (android) device token so agent/AI replies reach the visitor when the app is backgrounded and no websocket is held. The device is always attached to the AUTHENTICATED visitor — there is no way to register a device for anybody else — and the bearer visitor token names the tenant, exactly like the rest of the widget surface.
The token is a push credential: it is stored encrypted at rest, never returned in a response, and never logged. Re-registering the same token for the same visitor is idempotent (the row is upserted, not duplicated), so the SDK may register on every boot and after every APNs/FCM token rotation. Flag-gated (feature.messaging.mobile_push) and throttled.
| Idempotency-Key | string <= 255 characters |
| platform required | string Enum: "ios" "android" ios → APNs, android → FCM. |
| token required | string <= 4096 characters The opaque APNs/FCM device token. A push credential — write-only; never echoed back. |
{- "platform": "ios",
- "token": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "platform": "ios",
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-263 — drop a device so it stops receiving pushes, e.g. on logout. The id is pinned to the authenticated visitor's own devices, so another visitor's or another tenant's device id 404s rather than 403s — the widget principal cannot even learn it exists. Idempotent from the caller's view. Never gated by the feature flag: a device already holding pushes must always be able to switch them off.
| deviceId 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-218 (Doc 07 §2.2 — "agent performs the steps with confirmation"). The execution runtime asks to run a published guide's steps on the page the visitor is on; THIS endpoint is the security boundary that decides whether it may. Requires the feature.automation.execute flag (404 while dark, rule 7) and a valid visitor token.
The server enforces, IN ORDER: the feature flag; the domain allowlist (PA-219 AutomationDomainGuard — the host derived from url must have a verified allowlist row); the sensitive-page rail (PA-219 SensitivePagePolicy — no execution on payment/password pages). A client-side check is bypassable, so the decision is made HERE and cannot be talked out of.
On refusal the run is still AUDITED: an aborted run is recorded with the matching reason (domain_not_allowed or sensitive_page) and the endpoint returns 422 carrying that reason. On success a running run is opened and its id returned; the runtime then records each executed step against it.
| Idempotency-Key | string <= 255 characters |
| guide_id required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| url required | string <= 2048 characters The visitor's current page URL. Its host is derived for the allowlist check and the whole URL is matched against the sensitive-page rail. Never stored as a run value — only the derived host is recorded. |
{- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string"
}{- "data": {
- "run_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "running",
- "abort_reason": "string"
}
}PA-218 — move a running run to a terminal state: completed (every step ran), aborted (the user stopped it, or a client-side rail such as selector ambiguity fired), or failed. abort_reason is required when the status is aborted and must be absent otherwise.
Pinned to the authenticated visitor's OWN run — another visitor's or another tenant's run id 404s. Deliberately NOT flag-gated: a running run must always be closable (the Stop button can never go dark), exactly as ATH-263 leaves device unregister ungated. Idempotent — a run already terminal is returned unchanged.
| runId 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 |
| status required | string Enum: "completed" "aborted" "failed" |
| abort_reason | string Enum: "user_stopped" "selector_ambiguous" "sensitive_page" "domain_not_allowed" "error" Required when |
{- "status": "completed",
- "abort_reason": "user_stopped"
}{- "data": {
- "run_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "running",
- "abort_reason": "string"
}
}PA-283 — resume the SAME audited "do it for me" run after a full-page navigation. Before the client may present the next consent step, the server re-runs the execution safety rails against the NEW page: the feature.automation.execute flag, the verified-domain allowlist, and the workspace-sensitive-page policy. A client-side check is bypassable, so a persisted cursor alone never authorizes execution on a new page.
The run is pinned to the authenticated visitor's own workspace and initiator_visitor_ref; another visitor's or tenant's run id 404s. The run must still be running, otherwise the endpoint returns 409. On a safety-rail refusal the EXISTING run is closed as aborted with domain_not_allowed or sensitive_page, and 422 returns that same run id. Nothing here carries a typed or pre-filled value: the client stores and sends only the run identity, guide identity, next step index, and current page URL.
| runId 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 |
| url required | string <= 2048 characters The newly loaded page URL. The host is checked against the verified allowlist and the whole URL against the sensitive- page rail. The URL itself is never stored on the run. |
{- "url": "string"
}{- "data": {
- "run_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "running",
- "abort_reason": "string"
}
}PA-218 — append one executed step to a running run. Idempotent on (run, step_index) (rule 5): a retry of the same index returns the row already written and changes nothing. Pinned to the authenticated visitor's OWN run — another visitor's or another tenant's run id 404s. A step on a run that is no longer running is a 409.
RULE 3 IS THE POINT OF THIS PAYLOAD. There is deliberately NO field that could carry a typed value. A step names WHICH selector resolved the target (selector_used) and THAT an action happened — never the text entered. A field the runtime must not type into (masked or sensitive) is reported with sensitive_manual: true and outcome: skipped_manual, i.e. "the user did this by hand", never its contents. Value-bearing keys (value, text, input, …) are rejected with 422, not merely dropped.
| runId 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 |
| step_index required | integer >= 0 Zero-based position of this step in the run's execution order. |
| guide_step_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| action required | string Enum: "click" "fill" "navigate" "select" "submit" "scroll" "keypress" What the runtime did. |
| selector_used | string <= 2048 characters WHICH selector resolved the target — the "where", never the "what". |
| outcome required | string Enum: "ok" "failed" "skipped_manual" "aborted" |
| sensitive_manual | boolean Default: false True when the field was handed to the user for manual entry (masked/sensitive). |
| duration_ms | integer >= 0 Default: 0 |
| screenshot_ref | string <= 1024 characters A storage reference to an optional screenshot — never an inline image. |
{- "step_index": 0,
- "guide_step_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "action": "click",
- "selector_used": "string",
- "outcome": "ok",
- "sensitive_manual": false,
- "duration_ms": 0,
- "screenshot_ref": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "step_index": 0,
- "action": "click",
- "outcome": "ok",
- "sensitive_manual": true
}
}ATH-135 — current page + honest browser-reported timezone and locale, merged into the visitor context; publishes ephemeral visitor.page_changed. IP geolocation (the map) is founder-gated on a geo provider.
| Idempotency-Key | string <= 255 characters |
| url required | string <= 500 characters |
| title | string or null <= 255 characters |
| timezone | string or null <= 64 characters |
| locale | string or null <= 16 characters |
| referrer | string or null <= 500 characters |
{- "url": "string",
- "title": "string",
- "timezone": "string",
- "locale": "string",
- "referrer": "string"
}{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}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": {
- "configured": true,
- "secret": "string"
}
}ATH-118 — rotation invalidates every outstanding signature; the customer's backend must re-sign with the new secret.
| 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-111 — on reconnect, send the channel and your highest seen cursor; receive every DURABLE event after it in cursor order. Ephemeral events (typing, presence, page telemetry) are never replayed. Authorization is the same ChannelAuthorizer that grants live subscriptions. resync=true means continuity can't be proven (cursor predates retention) — refetch state via REST instead of trusting the replay.
| channel required | string <= 200 characters Wire channel name (private-…/presence-…) or bare stem |
| after | integer >= 0 Default: 0 |
{- "data": [
- {
- "cursor": 0,
- "name": "message.created",
- "payload": { },
- "created_at": "2019-08-24T14:15:22Z"
}
], - "latest_cursor": 0,
- "complete": true,
- "resync": true
}ATH-111 — identical contract to /realtime/resume, authenticated by the bearer visitor token; grants limited to the visitor's own channels.
| channel required | string <= 200 characters |
| after | integer >= 0 Default: 0 |
{- "data": [
- {
- "cursor": 0,
- "name": "message.created",
- "payload": { },
- "created_at": "2019-08-24T14:15:22Z"
}
], - "latest_cursor": 0,
- "complete": true,
- "resync": true
}Requires guides.view. 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": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "subject_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "format": "string",
- "status": "queued",
- "filename": "string",
- "error": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-105 — queued render into S3; the requester is emailed when ready. Formats come from the exporter registry. ATH-106 adds pdf (headless Chromium render of the print stylesheet, brand kit applied); ATH-107 adds the zip bundles (html_zip: index.html + images/ as files; markdown_zip: guide.md + images/ with relative links); ATH-108 adds docx (PHPWord — title, numbered steps, embedded screenshots). ATH-244 adds video (server-side render of the step sequence to MP4; large, so it carries an expires_at retention window). video is offered only when feature.content.video_export is enabled for the workspace — requesting it otherwise is a 422, the same as an unknown format.
| 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 |
| subject_id required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| format required | string Enum: "markdown" "html" "pdf" "html_zip" "markdown_zip" "docx" "video" |
{- "subject_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "format": "markdown"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "subject_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "format": "string",
- "status": "queued",
- "filename": "string",
- "error": "string",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}Requires analytics.view. 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 |
| from | string <date> |
| to | string <date> |
{- "data": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "metrics": [
- {
- "metric": "string",
- "label": "string",
- "total": 0,
- "series": [
- {
- "period": "string",
- "value": 0
}
]
}
], - "by_channel": [
- {
- "key": "string",
- "value": 0
}
]
}
}Requires analytics.view. 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 |
| from | string <date> |
| to | string <date> |
{- "data": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "metrics": [
- {
- "metric": "string",
- "label": "string",
- "total": 0,
- "series": [
- {
- "period": "string",
- "value": 0
}
]
}
], - "top_search_terms": [
- {
- "key": "string",
- "value": 0
}
], - "zero_result_terms": [
- {
- "key": "string",
- "value": 0
}
]
}
}ATH-259, Doc 06 §3.3. Requires analytics.view; 404 below feature.analytics.dashboard (the Growth+ plan entitlement). Every number is derived from an existing source rather than a parallel counter: run outcomes come from agent_runs (ATH-160), credit spend from the credit_ledger debits (ATH-166), and the deflection block from the ATH-285 ROI rollup read through the Metrics port. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| from | string <date> |
| to | string <date> |
{- "data": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "metrics": [
- {
- "metric": "string",
- "label": "string",
- "total": 0,
- "series": [
- {
- "period": "string",
- "value": 0
}
]
}
], - "rates": {
- "answer_rate": 0,
- "escalation_rate": 0,
- "automated_resolution_rate": 0
}, - "escalation_reasons": [
- {
- "key": "string",
- "value": 0
}
], - "deflection": {
- "available": true,
- "deflections": 0,
- "escalations": 0,
- "deflection_rate": 0,
- "value_cents": 0,
- "value_per_contact_cents": 0
}
}
}PA-226 (ATH-304), Doc 07 §3 — the content team's value made measurable: per article, the conversations it deflected, the agent-minutes that saved, and the question-cluster volume it now covers. Requires analytics.view; 404 below feature.mining.roi_dashboard (rule 7). Every number is DERIVED from the ATH-285 deflection-ROI rollup read through the Metrics port (deflection.by_source, deflection.total, deflection.minutes) — this surface recomputes nothing and never scans agent_runs or events. Agent-minutes-saved is avoided conversations × a configurable average handle time (handle_minutes_per_contact, CAIRN_DEFLECTION_HANDLE_MINUTES), surfaced alongside the multiplier so the math is auditable. top_intents ranks the deflected question themes by volume; revenue_signal_available is false and every intent's revenue_linked is false because the platform has no per-conversation revenue signal yet — the ranking is the honest volume proxy for "revenue-touching intents", not a fabricated revenue number. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| from | string <date> |
| to | string <date> |
{- "data": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "available": true,
- "revenue_signal_available": true,
- "value_per_contact_cents": 0,
- "handle_minutes_per_contact": 0,
- "deflections_total": 0,
- "agent_minutes_saved": 0,
- "value_cents": 0,
- "per_article": [
- {
- "article_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "deflections": 0,
- "value_cents": 0,
- "agent_minutes_saved": 0,
- "question_cluster_volume": 0,
- "covered_cluster_count": 0
}
], - "top_intents": [
- {
- "intent": "string",
- "deflections": 0,
- "value_cents": 0,
- "revenue_linked": true
}
]
}
}PA-229 (Doc 07 §4) — the tuning dashboard over the nudge funnel: shown → engaged → walkthrough started → helped → conversation avoided. Requires analytics.view; 404 below feature.guidance.nudges (rule 7). Every count is DERIVED from the nudge_events log through the metric registry (the same NudgeEventCountSource a custom dashboard resolves), so this surface owns no parallel counter. The rates are arithmetic on those counts (a ratio does not sum across days, so it is computed here, not registered). conversations_avoided is nudges_shown − conversations_started: a nudge that did not lead the visitor into a support conversation deflected one — the honest proxy while the platform has no per-conversation revenue signal. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| from | string <date> |
| to | string <date> |
{- "data": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "available": true,
- "conversations_avoided": 0,
- "metrics": [
- {
- "metric": "string",
- "label": "string",
- "total": 0,
- "series": [
- {
- "period": "string",
- "value": 0
}
]
}
], - "rates": {
- "engagement_rate": 0,
- "help_rate": 0,
- "avoidance_rate": 0
}
}
}ATH-259, Doc 06 §3.4. The whitelist of registered metrics — the picker vocabulary. A dashboard references these ids and nothing else; an id absent from this list is a 422 on write. Clients must not hardcode ids, because this list is what validation measures against. 404 below feature.analytics.custom_dashboards.
| 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": "ai_answered",
- "label": "AI answered",
- "category": "support",
- "unit": "count"
}
]
}ATH-259, Doc 06 §3.4. The workspace's saved dashboards, newest first. Returns the STORED shape (name + referenced metric ids); resolving a dashboard's numbers is a separate call, so listing never runs every metric in every dashboard. 404 below feature.analytics.custom_dashboards.
| 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",
- "widgets": [
- {
- "metric": "ai_answered",
- "viz": "stat"
}
], - "created_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-259, Doc 06 §3.4. widgets[].metric must be an id from listDashboardMetrics; an unregistered id is a 422. Nothing user-supplied reaches SQL — a dashboard stores references, never a query. 404 below feature.analytics.custom_dashboards.
| 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 <= 120 characters |
required | Array of objects (DashboardWidget) [ 1 .. 24 ] items |
{- "name": "string",
- "widgets": [
- {
- "metric": "ai_answered",
- "viz": "stat"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "widgets": [
- {
- "metric": "ai_answered",
- "viz": "stat"
}
], - "created_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-259, Doc 06 §3.4. Each widget's metric id is resolved back through the registry to its source and evaluated over the window; series widgets additionally carry day buckets. A widget whose metric has since been retired from the registry reports available: false rather than rendering a zero — a removed metric must read as "gone", never as "no activity". Another tenant's dashboard is a 404.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| dashboardId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| from | string <date> |
| to | string <date> |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z",
- "widgets": [
- {
- "metric": "string",
- "label": "string",
- "unit": "count",
- "viz": "stat",
- "available": true,
- "total": 0,
- "series": [
- {
- "period": "string",
- "value": 0
}
]
}
]
}
}ATH-259, Doc 06 §3.4. Both fields optional; widgets carries the same registry whitelist as the create path, so an update cannot name a metric that resolves to no source.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| dashboardId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string <= 120 characters |
Array of objects (DashboardWidget) [ 1 .. 24 ] items |
{- "name": "string",
- "widgets": [
- {
- "metric": "ai_answered",
- "viz": "stat"
}
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "widgets": [
- {
- "metric": "ai_answered",
- "viz": "stat"
}
], - "created_by": "01j8me9fycv0q4t4c7wz8k2xt1",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-259, Doc 06 §3.4. Deletes one dashboard in this workspace. Another tenant's dashboard is a 404.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| dashboardId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Public, consent-gated. The widget/help-center/guide pages batch events here (keyed by the public widget key). Without consent:true nothing is recorded; event names are allowlisted. Not a session API.
| website_id required | string <= 40 characters |
| consent | boolean Default: false |
| visitor | string <= 64 characters anonymous client id |
required | Array of objects <= 50 items |
{- "website_id": "string",
- "consent": false,
- "visitor": "string",
- "events": [
- {
- "name": "string",
- "occurred_at": "2019-08-24T14:15:22Z",
- "properties": { }
}
]
}{- "data": {
- "accepted": 0,
- "reason": "string"
}
}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": 1,
- "y": 1,
- "w": 1,
- "h": 1
}, - "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"
}, - "alerts": [
- { }
], - "walkthrough": {
- "enabled": true,
- "alternate_text": "string",
- "input_placeholder": "string"
}, - "input": {
- "masked": true
}
}
], - "brand_profile_id": "string",
- "brand": {
- "primary_color": "string",
- "secondary_color": "string",
- "marker_style": "ring",
- "font": "string",
- "brand_profile_id": "string"
}, - "capture_session_id": "string"
}
}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"
}
}Per-workspace agent tools (Doc 05 §5, ATH-202): the tool registry surface plus the HTTP tool builder. A kind=http tool is a workspace-authored outbound call — base URL, method, templated path/query/headers/body, auth injected from the encrypted vault, and a response mapping that trims the payload before an agent sees it. Every such call passes an SSRF guard that refuses private, loopback, link-local, and cloud-metadata targets after DNS resolution and re-checks each redirect hop. Requires integrations.manage; the builder is gated on feature.ai.tool_builder.
ATH-202 — requires integrations.manage. Returns every tool the workspace has registered, of every kind. Credential PAYLOADS are never included; a tool reports only whether a vault credential is linked and under which key. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| kind | string Enum: "builtin" "http" "mcp" |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "kind": "builtin",
- "enabled": true,
- "description": "string",
- "credential_key": "string",
- "endpoint": "string",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. A kind=http tool's config is validated server-side before it is stored — endpoint URL against the SSRF guard's DNS-free rules, method against the read/write allow-list, templates against the declared parameter names, and static headers against carrying credentials (those belong in the vault). New tools are always created DISABLED — enabling is a separate PATCH, after the author has exercised the test-invoke endpoint.
| 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 [ 1 .. 120 ] characters |
| slug | string Unique per workspace. Derived from the name when omitted. |
| kind required | string Enum: "builtin" "http" "mcp" |
object For kind=http this must satisfy HttpToolConfig. | |
| credential_key | string or null Vault key, in THIS workspace, whose payload is injected as auth. |
{- "name": "string",
- "slug": "string",
- "kind": "builtin",
- "config": { },
- "credential_key": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "kind": "builtin",
- "enabled": true,
- "description": "string",
- "credential_key": "string",
- "endpoint": "string",
- "updated_at": "2019-08-24T14:15:22Z",
- "config": { },
- "parameters": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-202 — requires integrations.manage. Scoped to the workspace in the path — another workspace's toolId is a 404, never a read.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "kind": "builtin",
- "enabled": true,
- "description": "string",
- "credential_key": "string",
- "endpoint": "string",
- "updated_at": "2019-08-24T14:15:22Z",
- "config": { },
- "parameters": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. A supplied config is fully re-validated; enabling a tool whose config no longer validates is refused with 422 rather than left to fail at call time. Linking a credential names a vault KEY belonging to this workspace — a key from another tenant is a validation error, not a silent null.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 120 ] characters |
object | |
| credential_key | string or null |
| enabled | boolean Turning a tool on is what exposes it to the agent runtime. Refused with 422 when the tool's config does not validate. |
{- "name": "string",
- "config": { },
- "credential_key": "string",
- "enabled": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "kind": "builtin",
- "enabled": true,
- "description": "string",
- "credential_key": "string",
- "endpoint": "string",
- "updated_at": "2019-08-24T14:15:22Z",
- "config": { },
- "parameters": { },
- "created_at": "2019-08-24T14:15:22Z"
}
}ATH-202 — requires integrations.manage. The linked vault credential is NOT deleted: one credential can back several tools and removing a definition must not revoke a secret out from under the others.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. Give either url (fetched through the same SSRF guard as a tool call — a spec URL is the same vector) or document (a pasted JSON or YAML spec). Nothing is persisted; the response carries a ready-to-store config per importable operation plus an explicit refusals list naming every operation that was skipped and why. The importer supports the common shape (OpenAPI 3.0/3.1, path/query/header parameters, JSON request bodies, local $ref) and REFUSES the rest loudly rather than generating a tool that would call the wrong thing.
| 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 |
| url | string <uri> Location of the OpenAPI document. Fetched through the SSRF guard, size-capped, redirects re-checked. |
| document | string A pasted OpenAPI 3.0/3.1 document, JSON or YAML. |
| base_url | string Overrides the spec's servers[0].url. Required when the spec has none, has a relative one, or uses {variables}. |
{- "document": "string",
- "base_url": "string"
}{- "data": {
- "base_url": "string",
- "suggested_auth": "string",
- "operations": [
- {
- "operation_id": "string",
- "method": "string",
- "path": "string",
- "summary": "string",
- "config": {
- "description": "string",
- "parameters": { },
- "request": {
- "method": "GET",
- "path": "string",
- "query": {
- "property1": "string",
- "property2": "string"
}, - "headers": {
- "property1": "string",
- "property2": "string"
}, - "body": { },
- "auth": {
- "type": "none",
- "header": "string"
}, - "allow_writes": true,
- "timeout_seconds": 1,
- "max_response_bytes": 1024
}, - "response": {
- "root": "string",
- "fields": [
- "string"
], - "max_items": 1
}
}
}
], - "refusals": [
- {
- "operation": "string",
- "reason": "string"
}
]
}
}ATH-202 — requires integrations.manage and the feature.ai.tool_builder flag. Runs the tool with the supplied arguments and returns the mapped result envelope, so an author can see exactly what an agent would receive. Works on a DISABLED tool by design: a tool is fail-closed until someone has confirmed it works, so a try-it that honoured the enabled flag could never be used.
ATH-204 NARROWED THIS BYPASS. Only the enabled flag is skipped here. Permission rules are evaluated on this route exactly as they are for an agent, with caller kind human — so a rule that denies the call denies it here too, and a rule that requires approval opens an approval task and returns status=pending_approval WITHOUT running the tool. The earlier contract skipped the whole authorizer, which let an operator run a rule-denied or approval-gated tool straight from this button.
The SSRF guard, timeout, and response cap all still apply. When the call is allowed the request really is made, so a write-verb tool really writes.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
object The arguments an agent would pass, matching the tool's declared parameter schema. |
{- "arguments": { }
}{- "data": {
- "status": "ok",
- "output": { },
- "error": "string",
- "duration_ms": 0,
- "approval_request_id": "string"
}
}ATH-204 (Doc 05 §5) — requires integrations.manage and the feature.ai.tool_approvals flag. Rules are returned in EVALUATION order (priority ascending, then id). The first rule whose caller kind and argument constraints match decides the call. If no rule matches the call is DENIED — the engine is fail-closed, mirroring the enabled column's default-false posture, so a tool with no rules cannot be run by anything.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "caller_kind": "agent",
- "effect": "allow",
- "priority": 0,
- "constraints": [
- {
- "argument": "string",
- "operator": "eq",
- "value": null
}
], - "approval_ttl_minutes": 0,
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. A rule pairs a caller kind (agent, workflow, human, or any) and an optional set of argument constraints with an effect: allow runs the tool, require_approval opens an approval task and runs the tool only after a human approves, deny refuses outright. Constraints are ANDed; a constraint whose argument is missing or of the wrong type does NOT match, so a refund <= 5000 rule can never be satisfied by an absent amount.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| caller_kind required | string Enum: "agent" "workflow" "human" "any" |
| effect required | string Enum: "allow" "require_approval" "deny" |
| priority | integer [ 0 .. 10000 ] Default: 100 |
Array of objects (ToolArgumentConstraint) | |
| approval_ttl_minutes | integer or null [ 1 .. 43200 ] |
| description | string or null <= 500 characters |
{- "caller_kind": "agent",
- "effect": "allow",
- "priority": 100,
- "constraints": [
- {
- "argument": "string",
- "operator": "eq",
- "value": null
}
], - "approval_ttl_minutes": 1,
- "description": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "caller_kind": "agent",
- "effect": "allow",
- "priority": 0,
- "constraints": [
- {
- "argument": "string",
- "operator": "eq",
- "value": null
}
], - "approval_ttl_minutes": 0,
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. Editing a rule changes what FUTURE calls are allowed to do; approval tasks already opened keep the arguments and the effect they were created with, so a pending refund cannot change size because someone edited the rule while it sat in the queue.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ruleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| caller_kind | string Enum: "agent" "workflow" "human" "any" |
| effect | string Enum: "allow" "require_approval" "deny" |
| priority | integer [ 0 .. 10000 ] |
Array of objects (ToolArgumentConstraint) | |
| approval_ttl_minutes | integer or null [ 1 .. 43200 ] |
| description | string or null <= 500 characters |
{- "caller_kind": "agent",
- "effect": "allow",
- "priority": 10000,
- "constraints": [
- {
- "argument": "string",
- "operator": "eq",
- "value": null
}
], - "approval_ttl_minutes": 1,
- "description": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "caller_kind": "agent",
- "effect": "allow",
- "priority": 0,
- "constraints": [
- {
- "argument": "string",
- "operator": "eq",
- "value": null
}
], - "approval_ttl_minutes": 0,
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-204 — requires integrations.manage and the feature.ai.tool_approvals flag. Deleting the last rule for a tool does not disable the tool, it makes every call to it fail closed — which is the intended way to stop an agent using a tool without tearing down its configuration.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| toolId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ruleId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-205 (Doc 05 §5 "full audit in agent_actions table") — requires ai.view_runs and the feature.ai.agent_actions flag. One row per invocation attempt, whoever made it: the AI agent, a workflow action node, or an operator's try-it click. The row records who called, which tool, with what arguments, what the permission engine decided, who approved it when a human had to, and what came of it.
REDACTION. Arguments and results are stored REDACTED and TRUNCATED — values under keys that look like secrets are replaced with [redacted], long strings are cut, and large structures are capped. redacted_keys names the paths that were masked so a reader can tell "there was nothing here" from "there was something here and we did not keep it". Credential material from the per-workspace vault is never part of an audit row at all: it is injected downstream of the record and is not carried on the event the audit listens to.
RETENTION (PA-268). The arguments and result have an END DATE; the rest of the row does not. Past the payload retention window a daily sweep empties those two columns and stamps payload_scrubbed_at — the invocation record itself (who asked, which tool, what was decided, who approved, how it ended, when) is kept, because that record IS the audit. A workspace may also opt out of storing the payloads at all, from the first write, via settings.tools.store_action_payloads. Both states are DECLARED on the row rather than silent: see payload_scrubbed_at and payload_scrub_reason on the detail response.
Ordering is newest-first by id (ULIDs are time-ordered), which is a non-nullable column, so the cursor is stable across engines. Scoped to the workspace in the path — another tenant's actions are never visible.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| tool_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: tool_id=01j8me9fycv0q4t4c7wz8k2xt1 Only actions for this tool. |
| caller_kind | string Enum: "agent" "workflow" "human" Only actions made by this kind of caller. |
| status | string Enum: "ok" "error" "denied" "pending_approval" "expired" Only actions that ended in this state. |
| from | string <date-time> Only actions that occurred at or after this instant. |
| to | string <date-time> Only actions that occurred at or before this instant. |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "string",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "caller_user_id": "string",
- "decision": "allowed",
- "status": "ok",
- "reason": "string",
- "duration_ms": 0,
- "conversation_id": "string",
- "approval_request_id": "string",
- "decided_by_user_id": "string",
- "occurred_at": "2019-08-24T14:15:22Z"
}
]
}ATH-205 — requires ai.view_runs and the feature.ai.agent_actions flag. The full record: provenance, the rule that decided, the redacted arguments and result, the approver when there was one, and the outcome.
PA-268 — the payloads may legitimately be absent, and the row SAYS SO when they are: an empty arguments with a non-null payload_scrubbed_at means "there was something here and its retention window passed", not "the call carried no arguments". Always read payload_scrubbed_at before drawing a conclusion from an empty payload.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| actionId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "tool_id": "string",
- "tool_slug": "string",
- "tool_name": "string",
- "caller_kind": "agent",
- "caller_user_id": "string",
- "decision": "allowed",
- "status": "ok",
- "reason": "string",
- "duration_ms": 0,
- "conversation_id": "string",
- "approval_request_id": "string",
- "decided_by_user_id": "string",
- "occurred_at": "2019-08-24T14:15:22Z",
- "tool_permission_rule_id": "string",
- "origin_type": "string",
- "origin_id": "string",
- "approval_outcome": "string",
- "decided_at": "2019-08-24T14:15:22Z",
- "arguments": { },
- "result": { },
- "error": "string",
- "redacted_keys": [
- "string"
], - "payload_scrubbed_at": "2019-08-24T14:15:22Z",
- "payload_scrub_reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}The "do it for me" execution audit (Doc 07 §2.2, PA-220): the agent-driven runtime executes an executable guide's steps one at a time on the customer's own app, under a consent HUD, and every run and every executed step is logged. READ-ONLY here — the rows are written in-process by the execution engine (PA-218), never over HTTP. Steps record WHICH selector resolved and THAT an action happened, never a typed value (CLAUDE.md rule 3). Requires ai.view_runs and is dark below feature.automation.runs.
PA-220 (Doc 07 §2.2 — "every executed step logged (automation_runs, screenshots optional)") — requires ai.view_runs and the feature.automation.runs flag. One row per execution SESSION: the agent-driven runtime executing an executable guide's steps one at a time on the customer's own app, under a consent HUD. The row records who launched it, which guide (its title snapshotted so the record outlives the guide), the host it acted on, and how it ended.
NO TYPED VALUES (CLAUDE.md rule 3). This is the read half of an audit spine that structurally cannot hold captured input. A run's steps record WHICH field a fill touched (selector_used) and THAT a fill happened — never the text entered. A step handed to the user for manual entry because the field was masked or sensitive is flagged sensitive_manual, again with no value.
Ordering is newest-first by id (ULIDs are time-ordered), a non-nullable column, so the cursor is stable across engines. Scoped to the workspace in the path — another tenant's runs are never visible.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| guide_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: guide_id=01j8me9fycv0q4t4c7wz8k2xt1 Only runs that executed this guide. |
| status | string Enum: "running" "completed" "aborted" "failed" Only runs that ended in this state. |
| initiator_kind | string Enum: "visitor" "member" "agent" Only runs launched by this kind of initiator. |
| from | string <date-time> Only runs that started at or after this instant. |
| to | string <date-time> Only runs that started at or before this instant. |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "string",
- "guide_title": "string",
- "conversation_id": "string",
- "initiator_kind": "visitor",
- "initiator_user_id": "string",
- "initiator_visitor_ref": "string",
- "host": "string",
- "status": "running",
- "abort_reason": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z"
}
]
}PA-220 — requires ai.view_runs and the feature.automation.runs flag. The full run with its ordered steps. For each step: the action, WHICH selector resolved (selector_used), the outcome, whether it was handed to the user for manual entry (sensitive_manual), and an optional screenshot STORAGE REFERENCE — never an inline image, and never a typed value (CLAUDE.md rule 3).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| runId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "string",
- "guide_title": "string",
- "conversation_id": "string",
- "initiator_kind": "visitor",
- "initiator_user_id": "string",
- "initiator_visitor_ref": "string",
- "host": "string",
- "status": "running",
- "abort_reason": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "ended_at": "2019-08-24T14:15:22Z",
- "steps": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "step_index": 0,
- "guide_step_id": "string",
- "action": "click",
- "selector_used": "string",
- "outcome": "ok",
- "sensitive_manual": true,
- "error": "string",
- "duration_ms": 0,
- "screenshot_ref": "string",
- "occurred_at": "2019-08-24T14:15:22Z"
}
], - "created_at": "2019-08-24T14:15:22Z"
}
}PA-219 (Doc 07 §2.2 — "only runs on customer's own product domains (workspace-verified domain allowlist)") — requires settings.security and the feature.automation.allowlist flag (404 below it). The execution engine (PA-218) may drive a page ONLY when its host has a verified row here; adding a host is not enough, ownership must be proven. This is the security boundary, enforced server-side — a client-side check is bypassable.
Each row carries the verification_token the workspace must serve at well_known_path on the host to prove control of it. Status moves pending → verified/failed via the async check.
| 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",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "host": "string",
- "status": "pending",
- "verification_method": "http_well_known",
- "verification_token": "string",
- "well_known_path": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}PA-219 — requires settings.security and the feature.automation.allowlist flag. The host is normalised (lowercased, scheme/port/path stripped) and must be unique within the workspace. Verification is asynchronous: the row returns pending with a verification_token, and the workspace proves control by serving that token at well_known_path over HTTPS, after which the check flips the row to verified. Only a verified host authorises execution.
| 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 |
| host required | string The product host to allow, e.g. app.customer.com. A full URL is accepted and normalised down to its host (scheme, port and path are stripped); the result is lowercased on save. |
{- "host": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "host": "string",
- "status": "pending",
- "verification_method": "http_well_known",
- "verification_token": "string",
- "well_known_path": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}PA-219 — requires settings.security and the feature.automation.allowlist flag. Another tenant's id 404s under the workspace scope (rule 2).
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| domainId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "host": "string",
- "status": "pending",
- "verification_method": "http_well_known",
- "verification_token": "string",
- "well_known_path": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}PA-219 — removing the row immediately withdraws execution authorisation for the host: the guard reads this table live, so the next run on that host is refused.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| domainId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-219 — the "check it now" endpoint. A domain is added pending BEFORE the workspace has served the token, so its first automatic check fails; once the token is in place the workspace calls this to re-check. Queues a fresh check; poll the list or show endpoint for the outcome. Idempotent and throttled.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| domainId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "host": "string",
- "status": "pending",
- "verification_method": "http_well_known",
- "verification_token": "string",
- "well_known_path": "string",
- "verified_at": "2019-08-24T14:15:22Z",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}Context-aware proactive guidance (Doc 07 §4, E32): the page↔knowledge map that links URL patterns to the guides relevant there. Manual pins (curated: "pin this guide to /settings/billing", optionally to one element via a selector) plus the automatic associations derived from the per-step URLs guides carry from capture. The map query is the read the nudge engine (PA-229) and the employee Sidekick (PA-230) build on. Dark below feature.guidance.page_map.
PA-228 (Doc 07 §4) — the manual half of the page↔knowledge map: every guide a workspace has pinned to a URL pattern, newest first. A pin makes a guide relevant to the pages its url_pattern matches, optionally anchored to one element by selector (element-level pinning, Scribe parity). Requires guides.edit_any and the feature.guidance.page_map flag (404 below it).
| 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",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_title": "string",
- "guide_status": "string",
- "url_pattern": "string",
- "match_type": "exact",
- "selector": "string",
- "created_by_user_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}PA-228 — pin a guide so it surfaces on pages matching url_pattern. match_type chooses how the pattern is compared to a page URL: exact uses the tolerant walkthrough-player rule (origin + path equality up to a trailing slash, query subset, hash only when the pattern carries one — the same clients/walkthrough urlMatches semantics the auto map uses, so client and server agree), prefix matches a path and everything under it, glob matches a * wildcard path. selector is optional and pins the guide to one element. One pin per (guide, url_pattern) per workspace. Requires guides.edit_any and the feature.guidance.page_map flag.
| 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 |
| guide_id required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| url_pattern required | string <= 2048 characters The URL or path pattern to pin the guide to, e.g. /settings/billing. |
| match_type | string Enum: "exact" "prefix" "glob" How the pattern is matched against a page URL. Optional; defaults to |
| selector | string or null <= 1024 characters Optional CSS selector for element-level pinning. |
{- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url_pattern": "string",
- "match_type": "exact",
- "selector": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_title": "string",
- "guide_status": "string",
- "url_pattern": "string",
- "match_type": "exact",
- "selector": "string",
- "created_by_user_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-228 — change where a pinned guide surfaces. The guide a pin points at is fixed; re-pin to move a guide to another. Another tenant's pin id 404s under the workspace scope (rule 2). Requires guides.edit_any and the feature.guidance.page_map flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pinId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| url_pattern | string <= 2048 characters |
| match_type | string Enum: "exact" "prefix" "glob" |
| selector | string or null <= 1024 characters Pass null to clear element-level pinning. |
{- "url_pattern": "string",
- "match_type": "exact",
- "selector": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "guide_title": "string",
- "guide_status": "string",
- "url_pattern": "string",
- "match_type": "exact",
- "selector": "string",
- "created_by_user_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}PA-228 — unpin a guide from a URL pattern. The AUTO association (if the guide has a step whose captured URL matches) is unaffected — only the manual pin is removed. Requires guides.edit_any and the feature.guidance.page_map flag.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| pinId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}PA-228 (Doc 07 §4) — given a page URL, the guides relevant there, ranked pinned-first. This is THE read the nudge engine (PA-229) and the employee Sidekick (PA-230) build on. Two sources are merged and de-duplicated by guide: MANUAL pins whose url_pattern matches the URL (carrying the optional selector), and AUTO associations — guides with a step whose captured URL matches, using the same tolerant urlMatches rule the walkthrough player uses, so client and server agree on what "matches". A guide that is both pinned and auto-matched appears once, as pinned. AUTO entries are limited to published guides; a pinned entry carries its guide's status so the caller can decide. Requires guides.view (every member — so the Sidekick's audience is not locked out) and the feature.guidance.page_map flag (404 below it). Matching runs in PHP over the workspace's own pins and step URLs — never engine-specific SQL — so sqlite and Postgres agree.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| url required | string <= 2048 characters The page URL to resolve relevant guides for. |
{- "data": [
- {
- "guide_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "string",
- "source": "pinned",
- "pin_id": "string",
- "url_pattern": "string",
- "match_type": "string",
- "selector": "string",
- "matched_step_url": "string"
}
]
}PA-230 (Doc 07 §4) — the copilot-search half of the employee Sidekick: a conversation-less, member-authed knowledge query that returns a grounded, cited answer over the workspace's internal audience ONLY. It is the standalone counterpart to the conversation-scoped agent-assist copilot (runCopilot): same retrieval + grounding + citation components, but no conversation, no draft, and no delivery.
AUDIENCE SCOPE is the correctness property. Retrieval is pinned to internal in the Action (never a caller parameter), so this surface can never return customers- or public-audience chunks, and — like every retrieval leg — it is workspace-scoped in SQL, so another tenant's internal knowledge is unreachable. Every cited source is therefore internal.
Requires ai.view_runs — an AI permission (this spends AI credits and reads internal knowledge), analyst-grade and NOT admin-only, so the internal team the Sidekick exists for can use it. Plan-gated on feature.guidance.sidekick (404 below it, rule 7). Every call is metered through the LLM gateway and debits AI credits; an exhausted workspace gets 402. Grounding floor as the copilot: when nothing internal answers the query it declines and charges nothing rather than inventing.
| 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 |
| query required | string [ 2 .. 2000 ] characters What the employee is asking the internal knowledge base. |
{- "query": "string"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "query": "string",
- "answer": "string",
- "grounded": true,
- "confidence": 0,
- "sources": [
- {
- "kind": "article",
- "ref": "string",
- "title": "string",
- "slug": "string",
- "audience": "internal"
}
], - "credit_cost_cents": 0
}
}External MCP servers (Doc 05 §5, ATH-203) — Cairn as an MCP CLIENT, the opposite direction to the Cairn MCP server under Search. A workspace attaches someone else's MCP server; a discover call performs the handshake and tools/list and materialises what it finds as ordinary kind=mcp tools, so permission rules, the agent_actions audit, workflow action nodes and the agent runtime all apply unchanged. Only the remote Streamable HTTP transport is supported; stdio (which would mean running a local process per workspace) is deliberately not, and every request passes the same SSRF guard the HTTP tool builder uses. Discovered tools are ALWAYS created disabled and get no agent-tier permission rule — a third party describes its own tools and those descriptions reach an LLM prompt, so enabling one is a human decision every time. Requires integrations.manage and the feature.ai.mcp_client flag.
ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag (404 without it). Credential PAYLOADS are never included; a server reports only whether a vault credential is linked and under which key. Scoped to the workspace in the path.
| workspaceId 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 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true,
- "protocol_version": "string",
- "server_info": { },
- "last_discovered_at": "2019-08-24T14:15:22Z",
- "last_discovery_error": "string",
- "discovered_tool_count": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag. The endpoint URL is checked against the SSRF guard's DNS-free rules before it is stored, and again with DNS resolution on every outbound call. Only the remote http transport (MCP Streamable HTTP) is accepted; stdio is refused, because running a local process per workspace is a different isolation problem than this platform solves. Creating a server discovers NOTHING — call the discover operation to see what it offers.
| 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 [ 1 .. 120 ] characters |
| slug | string <= 60 characters Lowercase letters, digits and hyphens. Derived from the name when omitted. |
| url required | string <uri> <= 2048 characters |
| transport | string Value: "http" |
| auth_type | string Enum: "none" "bearer" "api_key_header" |
| auth_header | string or null <= 120 characters |
| credential_key | string or null Vault key, in THIS workspace, whose payload is injected as auth. |
| timeout_seconds | integer [ 1 .. 60 ] |
| max_response_bytes | integer [ 4096 .. 1048576 ] |
| enabled | boolean |
{- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true,
- "protocol_version": "string",
- "server_info": { },
- "last_discovered_at": "2019-08-24T14:15:22Z",
- "last_discovery_error": "string",
- "discovered_tool_count": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-203 — requires integrations.manage. Another workspace's serverId is a 404, never a read.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| serverId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true,
- "protocol_version": "string",
- "server_info": { },
- "last_discovered_at": "2019-08-24T14:15:22Z",
- "last_discovery_error": "string",
- "discovered_tool_count": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-203 — requires integrations.manage. slug and transport are immutable: every tool discovered from this server carries the slug in its own, and those tool slugs are stable so that permission rules and audit history stay attached. Setting enabled to false is the kill switch — every tool on this server stops executing without any of them being deleted.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| serverId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 120 ] characters |
| url | string <uri> <= 2048 characters |
| auth_type | string Enum: "none" "bearer" "api_key_header" |
| auth_header | string or null <= 120 characters |
| credential_key | string or null |
| timeout_seconds | integer [ 1 .. 60 ] |
| max_response_bytes | integer [ 4096 .. 1048576 ] |
| enabled | boolean |
{- "name": "string",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true,
- "protocol_version": "string",
- "server_info": { },
- "last_discovered_at": "2019-08-24T14:15:22Z",
- "last_discovery_error": "string",
- "discovered_tool_count": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-203 — requires integrations.manage. The tools discovered from this server are deleted with it: a tool that can never execute again must not keep sitting in the tool list reading enabled. Their agent_actions audit history SURVIVES (the FK nulls), so the record of what the agent did outlives the tool it did it with. The linked vault credential also survives — one credential can back several connections.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| serverId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-203 — requires integrations.manage and the feature.ai.mcp_client flag. Performs the MCP handshake (initialize + notifications/initialized) and tools/list, then reconciles the result into kind=mcp tool rows.
IDEMPOTENT. Re-running matches on (server, remote tool name) and UPDATES in place, so a tool keeps its id, slug, permission rules and audit history across any number of refreshes. It never duplicates a row and it NEVER sets enabled to true — a tool an admin disabled stays disabled however many times the server is re-listed.
TRUST. Discovered tools are created DISABLED with only an operator-tier permission rule, so the agent cannot call a newly discovered tool at all until someone writes a rule saying it may. A tool whose definition (name, description, argument schema) has CHANGED since it was enabled is switched back off with review_reason: definition_changed — approval attaches to what a human reviewed, not to a name the server can refill. A tool the server no longer offers is marked review_reason: removed_upstream and disabled, never deleted.
Failures (SSRF refusal, handshake error, timeout, oversized response) are 422 on url and are recorded on the server row.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| serverId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "created": 0,
- "updated": 0,
- "unchanged": 0,
- "disabled": 0,
- "skipped": 0,
- "protocol_version": "string",
- "tools": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "slug": "string",
- "name": "string",
- "remote_name": "string",
- "enabled": true,
- "change": "created",
- "review_reason": "string"
}
], - "server": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "slug": "string",
- "transport": "http",
- "auth_type": "none",
- "auth_header": "string",
- "credential_key": "string",
- "timeout_seconds": 1,
- "max_response_bytes": 4096,
- "enabled": true,
- "protocol_version": "string",
- "server_info": { },
- "last_discovered_at": "2019-08-24T14:15:22Z",
- "last_discovery_error": "string",
- "discovered_tool_count": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}No-code workflow builder (Doc 05 §6, ATH-191): CRUD over authored node graphs, server-side graph validation, and draft/publish. The runtime (ATH-190) triggers only PUBLISHED workflows; the editor writes a DRAFT graph and publish promotes it to the active graph the runtime reads. Requires workflows.manage; the builder itself is gated on feature.workflows.builder (Growth+).
ATH-191 — every node type registered on the runtime's NodeRegistry with the metadata the canvas editor renders its palette and per-node config forms from. PA-276 adds timeout_minutes to the ask_question descriptor so authored waits have a visible, bounded lifetime. Requires workflows.manage. Read-only, available on any plan.
| 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": [
- {
- "type": "string",
- "label": "string",
- "description": "string",
- "category": "trigger",
- "terminal": true,
- "singleton": true,
- "branches": [
- "string"
], - "config_fields": [
- {
- "name": "string",
- "label": "string",
- "type": "text",
- "required": true,
- "placeholder": "string",
- "help": "string",
- "max_length": 0,
- "min": 0,
- "max": 0,
- "options": [
- {
- "value": "string",
- "label": "string"
}
], - "depends_on": {
- "field": "string",
- "value": null
}
}
]
}
]
}ATH-194 — the first-party template library (Doc 05 §6 "template library"). Requires workflows.manage. Read-only and available on any plan, so the workflow list page can show what the Growth plan would unlock; INSTANTIATING one is the gated write.
Templates are code-defined and versioned with the application, not workspace rows: they are product content, and a shipped guard (WorkflowTemplateTest) proves every one of them still names registered node types, still names a trigger-eligible event, and still instantiates into a graph the editor's own validator accepts.
Each template declares the placeholders a workspace must fill in before the graph means anything — which segment, which tool, which message copy. Every placeholder is a string; a required one with no default must be supplied to instantiate.
| 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": "string",
- "name": "string",
- "description": "string",
- "category": "sales",
- "trigger_event": "string",
- "node_count": 0,
- "default_name": "string",
- "placeholders": [
- {
- "key": "string",
- "label": "string",
- "help": "string",
- "kind": "text",
- "required": true,
- "default": "string",
- "max_length": 0
}
]
}
]
}ATH-194 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Copies the template's graph into a new workflow in this workspace with the supplied placeholder values substituted, and returns it exactly as createWorkflow would.
The result is always a DRAFT, never published: a template is a starting point an author is expected to read, adjust and simulate, and putting a flow in front of customers stays a deliberate human act. graph is therefore null on the response and draft_graph carries the instantiated graph.
A missing required placeholder, a blank one, or a key the template does not declare is a 422 under error.details.placeholders.<key>.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| templateId required | string <= 64 characters The template slug from listWorkflowTemplates. |
| Idempotency-Key | string <= 255 characters |
| name | string [ 1 .. 120 ] characters Overrides the template's default_name. |
object Placeholder key to value. Every value is a string. |
{- "name": "string",
- "placeholders": {
- "property1": "string",
- "property2": "string"
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-191 — requires workflows.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 |
| status | string Enum: "draft" "published" "archived" |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "meta": {
- "next_cursor": "string"
}, - "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). A graph, if supplied, is validated server-side; when omitted a minimal trigger-only starter graph is seeded so the canvas has an entry node. Requires workflows.manage. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| 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 [ 1 .. 120 ] characters |
object (WorkflowTrigger) ATH-191 — what starts the workflow (Doc 05 §6). | |
object (WorkflowGraph) ATH-191 — the authored graph: nodes + edges. |
{- "name": "string",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-191 — requires workflows.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 |
| workflowId 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",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). A supplied graph is the DRAFT graph and is validated server-side: every node's type must be registered and its config valid, edges must reference existing nodes, exactly one trigger must exist, every node must be reachable from it, and no synchronous loop may exist that the runtime's step guard would only trip at run time. Validation failures return 422 with per-node field errors under error.details (keyed graph.nodes.<id> / graph). The active (published) graph is untouched until publish.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| workflowId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 120 ] characters |
object (WorkflowTrigger) ATH-191 — what starts the workflow (Doc 05 §6). | |
object (WorkflowGraph) ATH-191 — the authored graph: nodes + edges. |
{- "name": "string",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-191 — requires workflows.manage. Available on any plan so a downgraded workspace can still clean up. Scoped to the workspace in the path — a member of another workspace gets 403, never another tenant's rows.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| workflowId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Re-validates the draft graph (422 on failure) then copies it to the active graph, flips status to published, and bumps version on re-publish. From here the runtime (ATH-190) triggers the workflow.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| workflowId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-191 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Flips status to draft; the runtime stops triggering it. The last-published graph stays on the row so re-publishing without edits is a no-op promotion. In-flight runs finish against their own snapshot.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| workflowId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "status": "draft",
- "version": 0,
- "node_count": 0,
- "trigger_event": "string",
- "published": true,
- "updated_at": "2019-08-24T14:15:22Z",
- "trigger": {
- "event": "string",
- "filters": { }
}, - "graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "draft_graph": {
- "nodes": [
- {
- "id": "string",
- "type": "string",
- "config": { },
- "position": {
- "x": 0,
- "y": 0
}
}
], - "edges": [
- {
- "from": "string",
- "to": "string",
- "condition": "string"
}
]
}, - "created_at": "2019-08-24T14:15:22Z"
}
}ATH-193 — requires workflows.manage AND the feature.workflows.builder entitlement (422 below it). Walks the DRAFT graph (that is the point — an author sees what an unpublished change would do) against a hypothetical subject and returns the full step trace, branch by branch, with the run context after each step. The draft is re-validated first, so an invalid graph 422s with the same per-node keys the canvas already pins.
A simulation has NO side effects. It never persists a workflow run, never emits a domain event, and never reaches a real customer: every node type that could touch anything outside the run is replaced by a stand-in that reports what it WOULD do. Node types the simulator does not know how to stand in for are refused rather than executed, so a newly registered node cannot silently perform a real side effect here.
Parks are fast-forwarded rather than awaited: a delay reports "would wait 3 days" and execution continues; a question reports that it would wait for a reply and takes the scripted answers entry so both branches can be explored. outcomes scripts the branch a non-deterministic node (ai_answer, action) would take.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| workflowId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
object The trigger payload, seeded into the run context exactly as a real trigger would seed it. Condition nodes read it by dot-path (a node keyed | |
object Scripted replies for question nodes, keyed by the node's | |
object Scripted results for nodes whose real outcome cannot be known without doing the thing, keyed by node id. For |
{- "context": { },
- "answers": {
- "property1": "string",
- "property2": "string"
}, - "outcomes": {
- "property1": "string",
- "property2": "string"
}
}{- "data": {
- "workflow_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "completed",
- "step_count": 0,
- "error": "string",
- "steps": [
- {
- "index": 0,
- "node_id": "string",
- "node_type": "string",
- "result": "advanced",
- "branch": "string",
- "summary": "string",
- "detail": { },
- "wait": {
- "kind": "delay",
- "summary": "string",
- "seconds": 0,
- "event": "string",
- "scripted_answer": "string"
}, - "context": { },
- "error": "string"
}
], - "context": { }
}
}Tickets + the customer portal (Doc 04 §7, ATH-214). A ticket is NOT a second entity: per the doc, "tickets = conversations with kind=ticket, plus reference number, priority, SLA timers, custom ticket fields, portal visibility". These paths therefore read and write the SAME conversations rows the Inbox tag serves, and the thread is ordinary messages — reply, assign, and state transitions all reuse the Inbox endpoints rather than duplicating them here. Requires tickets.manage; gated on feature.support.ticketing (Scale+), which 404s below the plan. The end-customer portal is a server-rendered surface on the help-center host, not an API — its identity is the ATH-096 JWT handshake session resolved to a Contact, so it carries no bearer token and appears in no path here.
Requires tickets.manage; 404s below feature.support.ticketing. Returns only conversations with kind=ticket — the same rows the Inbox list serves, filtered. Ordered newest-first on the ULID primary key, which is both time-ordered and NOT NULL. The obvious alternative, last_message_at, is nullable — a freshly opened ticket has no message yet — and NULLs sort to opposite ends under sqlite and Postgres, so a cursor page would break differently in test and in production.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| state | string Enum: "pending" "open" "snoozed" "resolved" |
| priority | string Enum: "low" "normal" "high" "urgent" |
| assignee_id | string |
| breached | boolean Only tickets whose first-response or resolution SLA has been marked breached |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "reference": "string",
- "subject": "string",
- "state": "pending",
- "priority": "low",
- "channel": "string",
- "assignee_id": "string",
- "contact": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "string"
}, - "first_response_due_at": "2019-08-24T14:15:22Z",
- "resolution_due_at": "2019-08-24T14:15:22Z",
- "first_response_breached_at": "2019-08-24T14:15:22Z",
- "resolution_breached_at": "2019-08-24T14:15:22Z",
- "last_message_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Requires tickets.manage. Creates a conversation with kind=ticket, allocates the per-workspace reference, and computes the SLA due dates from the priority. The opening message, when supplied, goes through the ordinary message write path so the thread is identical to any other conversation.
| 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 |
| subject required | string <= 200 characters |
| contact_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| priority | string Default: "normal" Enum: "low" "normal" "high" "urgent" |
| assignee_id | string or null |
| body | string or null <= 10000 characters Opening message; written through the shared message path when present |
| portal_visible | boolean Default: false |
object |
{- "subject": "string",
- "contact_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "priority": "low",
- "assignee_id": "string",
- "body": "string",
- "portal_visible": false,
- "fields": { }
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "reference": "string",
- "subject": "string",
- "state": "pending",
- "priority": "low",
- "channel": "string",
- "assignee_id": "string",
- "contact": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "string"
}, - "first_response_due_at": "2019-08-24T14:15:22Z",
- "resolution_due_at": "2019-08-24T14:15:22Z",
- "first_response_breached_at": "2019-08-24T14:15:22Z",
- "resolution_breached_at": "2019-08-24T14:15:22Z",
- "last_message_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "portal_visible": true,
- "first_responded_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "fields": { }
}
}Requires tickets.manage. Scoped to the workspace in the path — a member of another workspace gets 404, never another tenant’s rows. Read the thread through the Inbox messages endpoint.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ticketId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "reference": "string",
- "subject": "string",
- "state": "pending",
- "priority": "low",
- "channel": "string",
- "assignee_id": "string",
- "contact": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "string"
}, - "first_response_due_at": "2019-08-24T14:15:22Z",
- "resolution_due_at": "2019-08-24T14:15:22Z",
- "first_response_breached_at": "2019-08-24T14:15:22Z",
- "resolution_breached_at": "2019-08-24T14:15:22Z",
- "last_message_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "portal_visible": true,
- "first_responded_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "fields": { }
}
}Requires tickets.manage. Changing priority RECOMPUTES the SLA due dates from the new target, unless the request also sets them explicitly. Assignment and state transitions are deliberately absent — they ride the existing Inbox assign and state endpoints, because a ticket is a conversation.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| ticketId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| subject | string <= 200 characters |
| priority | string Enum: "low" "normal" "high" "urgent" |
| portal_visible | boolean |
object | |
| first_response_due_at | string or null <date-time> |
| resolution_due_at | string or null <date-time> |
{- "subject": "string",
- "priority": "low",
- "portal_visible": true,
- "fields": { },
- "first_response_due_at": "2019-08-24T14:15:22Z",
- "resolution_due_at": "2019-08-24T14:15:22Z"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "reference": "string",
- "subject": "string",
- "state": "pending",
- "priority": "low",
- "channel": "string",
- "assignee_id": "string",
- "contact": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "email": "string"
}, - "first_response_due_at": "2019-08-24T14:15:22Z",
- "resolution_due_at": "2019-08-24T14:15:22Z",
- "first_response_breached_at": "2019-08-24T14:15:22Z",
- "resolution_breached_at": "2019-08-24T14:15:22Z",
- "last_message_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "portal_visible": true,
- "first_responded_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "fields": { }
}
}Public status page, components, incidents, and uptime monitors (Doc 04 §8, ATH-215). These paths are the ADMIN surface only — the status page a customer reads is a server-rendered, anonymous, full-page-cached surface on the help-center host (ATH-092/093), so it carries no bearer token and appears in no path here, exactly like the ATH-214 customer portal. Requires settings.manage; gated on feature.support.status_page (Scale+), which 404s below the plan. Monitors make outbound HTTP to workspace-supplied URLs and therefore run through ATH-202's SafeHttpClient/OutboundUrlGuard on every check and every redirect hop; a URL that fails the guard's DNS-free checks is rejected at authoring time with 422.
Requires settings.manage; 404s below feature.support.status_page. The settings row is created on first read, so a workspace that has never touched the status page still gets a well-formed (unpublished) payload rather than a 404.
| 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": {
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "published": true,
- "title": "string",
- "description": "string",
- "support_url": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. published is the master switch for the public surface — while it is false the public page 404s on every host, which is what makes "set the page up first, announce it later" possible. Any write here rotates the public page's full-page cache version.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| published | boolean |
| title | string [ 1 .. 120 ] characters |
| description | string or null <= 2000 characters |
| support_url | string or null <= 2048 characters |
{- "published": true,
- "title": "string",
- "description": "string",
- "support_url": "string"
}{- "data": {
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "published": true,
- "title": "string",
- "description": "string",
- "support_url": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. Ordered by position then by the ULID primary key — both NOT NULL, because a cursor page ordered on a nullable column pages differently under sqlite and Postgres. Unlike the public page this returns unpublished components too.
| workspaceId 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 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "status": "operational",
- "published": true,
- "position": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Requires settings.manage. A component is a thing customers recognise — API, Widget, Dashboard.
| 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 [ 1 .. 120 ] characters |
| description | string or null <= 2000 characters |
| status | string Default: "operational" Enum: "operational" "degraded" "partial_outage" "major_outage" "maintenance" |
| published | boolean Default: true |
| position | integer >= 0 |
{- "name": "string",
- "description": "string",
- "status": "operational",
- "published": true,
- "position": 0
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "status": "operational",
- "published": true,
- "position": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. Scoped to the workspace in the path — another tenant's component id 404s, never resolves. Setting published to false removes the component from the public page without deleting its history.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| componentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 120 ] characters |
| description | string or null <= 2000 characters |
| status | string Enum: "operational" "degraded" "partial_outage" "major_outage" "maintenance" |
| published | boolean |
| position | integer >= 0 |
{- "name": "string",
- "description": "string",
- "status": "operational",
- "published": true,
- "position": 0
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "description": "string",
- "status": "operational",
- "published": true,
- "position": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. Incident impact rows referencing it are removed with it.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| componentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Requires settings.manage. Newest-first on the ULID primary key, which is time-ordered AND NOT NULL; started_at would sort NULLs to opposite ends under sqlite and Postgres.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| status | string Enum: "investigating" "identified" "monitoring" "resolved" |
| active | boolean Only incidents that are not yet resolved |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "origin": "manual",
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Requires settings.manage. Opens at investigating unless a status is given, records the first update from body, and emits status.incident_opened so a workflow can notify however the workspace wants — the same detection/response split ATH-214 used for SLA breaches, rather than a bespoke notifier.
| 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 |
| title required | string [ 1 .. 200 ] characters |
| body required | string [ 1 .. 5000 ] characters The first update. Required, because an incident with no explanation tells a customer nothing they did not already know. |
| status | string Default: "investigating" Enum: "investigating" "identified" "monitoring" "resolved" |
| impact | string Default: "minor" Enum: "none" "minor" "major" "critical" |
| published | boolean Default: true |
| component_ids | Array of strings (Ulid) [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ] |
| started_at | string <date-time> |
{- "title": "string",
- "body": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "origin": "manual",
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "updates": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "investigating",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires settings.manage. Scoped to the workspace in the path — a member of another workspace gets 404, 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 |
| incidentId 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",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "origin": "manual",
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "updates": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "investigating",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires settings.manage. Deliberately does NOT move the lifecycle status — a status change is an UPDATE with a written explanation, so it goes through the updates endpoint. Editing an incident silently while customers watch is the failure mode a status page exists to prevent.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| incidentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| title | string [ 1 .. 200 ] characters |
| impact | string Enum: "none" "minor" "major" "critical" |
| published | boolean |
| component_ids | Array of strings (Ulid) [ items^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ] |
{- "title": "string",
- "impact": "none",
- "published": true,
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
]
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "origin": "manual",
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "updates": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "investigating",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires settings.manage. Updates are APPEND-ONLY — the timeline a customer read yesterday must still say what it said. Posting status=resolved stamps resolved_at and, for an incident a monitor opened, releases that monitor's claim so a future outage can open a new one. Emits status.incident_updated.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| incidentId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
| body required | string [ 1 .. 5000 ] characters |
| status | string Enum: "investigating" "identified" "monitoring" "resolved" Moves the incident's lifecycle. Omit to comment without changing it. |
{- "body": "string",
- "status": "investigating"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "title": "string",
- "status": "investigating",
- "impact": "none",
- "published": true,
- "origin": "manual",
- "component_ids": [
- "01j8me9fycv0q4t4c7wz8k2xt1"
], - "started_at": "2019-08-24T14:15:22Z",
- "resolved_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "updates": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "status": "investigating",
- "body": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}
}Requires settings.manage. Newest-first on the ULID primary key.
| workspaceId 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 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "method": "GET",
- "expected_status": 100,
- "interval_seconds": 60,
- "timeout_seconds": 1,
- "failure_threshold": 1,
- "recovery_threshold": 1,
- "component_id": "string",
- "enabled": true,
- "consecutive_failures": 0,
- "consecutive_successes": 0,
- "open_incident_id": "string",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "next_check_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}Requires settings.manage. The url is workspace-supplied and therefore attacker-controllable, so it is validated with ATH-202's OutboundUrlGuard DNS-free rules at authoring time — a loopback, link-local, internal-suffix, credential-bearing, or non-http(s) URL is 422 here rather than a request made later. Resolution-time checks still run on every sweep, because DNS can change after a save. See the tag description for the residual risk.
| 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 [ 1 .. 120 ] characters |
| url required | string <uri> <= 2048 characters |
| method | string Default: "GET" Enum: "GET" "HEAD" |
| expected_status | integer [ 100 .. 599 ] Default: 200 |
| interval_seconds | integer [ 60 .. 86400 ] Default: 300 |
| timeout_seconds | integer [ 1 .. 30 ] Default: 10 |
| failure_threshold | integer [ 1 .. 20 ] Default: 3 |
| recovery_threshold | integer [ 1 .. 20 ] Default: 2 |
| component_id | string or null |
| enabled | boolean Default: true |
{- "name": "string",
- "method": "GET",
- "expected_status": 200,
- "interval_seconds": 300,
- "timeout_seconds": 10,
- "failure_threshold": 3,
- "recovery_threshold": 2,
- "component_id": "string",
- "enabled": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "method": "GET",
- "expected_status": 100,
- "interval_seconds": 60,
- "timeout_seconds": 1,
- "failure_threshold": 1,
- "recovery_threshold": 1,
- "component_id": "string",
- "enabled": true,
- "consecutive_failures": 0,
- "consecutive_successes": 0,
- "open_incident_id": "string",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "next_check_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. A changed url is re-validated against the SSRF guard exactly as on create.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| monitorId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| name | string [ 1 .. 120 ] characters |
| url | string <uri> <= 2048 characters |
| method | string Enum: "GET" "HEAD" |
| expected_status | integer [ 100 .. 599 ] |
| interval_seconds | integer [ 60 .. 86400 ] |
| timeout_seconds | integer [ 1 .. 30 ] |
| failure_threshold | integer [ 1 .. 20 ] |
| recovery_threshold | integer [ 1 .. 20 ] |
| component_id | string or null |
| enabled | boolean |
{- "name": "string",
- "method": "GET",
- "expected_status": 100,
- "interval_seconds": 60,
- "timeout_seconds": 1,
- "failure_threshold": 1,
- "recovery_threshold": 1,
- "component_id": "string",
- "enabled": true
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "method": "GET",
- "expected_status": 100,
- "interval_seconds": 60,
- "timeout_seconds": 1,
- "failure_threshold": 1,
- "recovery_threshold": 1,
- "component_id": "string",
- "enabled": true,
- "consecutive_failures": 0,
- "consecutive_successes": 0,
- "open_incident_id": "string",
- "last_checked_at": "2019-08-24T14:15:22Z",
- "next_check_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Requires settings.manage. Recorded check results go with it; any incident it opened stays, because that incident is public history.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| monitorId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}Requires settings.manage. Newest-first on the ULID primary key. A check that the SSRF guard refused is recorded here as a failure carrying the guard's author-facing reason, so a workspace can see WHY its monitor never ran instead of watching silent downtime.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| monitorId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| cursor | string |
| limit | integer [ 1 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "ok": true,
- "status_code": 0,
- "latency_ms": 0,
- "error": "string",
- "checked_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}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": "article.feedback_submitted",
- "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-251 — requires webhooks.manage. The catalog is DERIVED from the domain events that have opted in to public delivery, never hand-maintained, so it cannot drift from what is actually deliverable. Subscribing to anything outside it is a 422.
| 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": [
- {
- "name": "article.feedback_submitted",
- "description": "string"
}
]
}ATH-251 — requires webhooks.manage. Newest-first on the ULID primary key. The signing secret is never included; it is returned exactly once, by the create call.
| workspaceId 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 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-251 — requires webhooks.manage. The url is workspace-supplied and therefore attacker-controllable, so it is validated with ATH-202's OutboundUrlGuard DNS-free rules here (loopback, link-local, cloud-metadata, internal suffixes, credential-bearing and numerically-encoded hosts, and non-http(s) schemes are all 422 on the url field) and re-checked WITH DNS resolution on every delivery attempt.
THE RESPONSE CARRIES THE SIGNING SECRET, and it is the only time it is ever returned. It is stored encrypted at rest and there is deliberately no reveal endpoint — a lost secret is rotated, not recovered.
| 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 |
| url required | string <= 2048 characters An http(s) URL that is not loopback, link-local, private, cloud-metadata, or an internal suffix. Validated without DNS here and with DNS on every delivery. |
| description | string or null <= 200 characters |
| events required | Array of strings [ 1 .. 50 ] items Event type names from the webhook-event-types catalog |
| status | string Default: "enabled" Enum: "enabled" "disabled" |
{- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}
}ATH-251 — requires webhooks.manage. Includes the failure counter and, when it was switched off automatically, the reason.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| endpointId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-251 — requires webhooks.manage. A changed url is re-validated against the SSRF guard exactly as on create. Setting status back to enabled after an automatic disable clears the failure counter and the recorded reason; it is the only way an auto-disabled endpoint resumes, because a redelivery must not quietly re-enable a host we have evidence is broken.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| endpointId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| url | string <= 2048 characters |
| description | string or null <= 200 characters |
| events | Array of strings [ 1 .. 50 ] items |
| status | string Enum: "enabled" "disabled" |
{- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled"
}{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "url": "string",
- "description": "string",
- "events": [
- "string"
], - "status": "enabled",
- "payload_version": "string",
- "disabled_at": "2019-08-24T14:15:22Z",
- "disabled_reason": "string",
- "consecutive_failures": 0,
- "last_success_at": "2019-08-24T14:15:22Z",
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-251 — requires webhooks.manage. Its subscriptions, deliveries and attempt history go with it; nothing is delivered to it afterwards.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| endpointId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-251 — requires webhooks.manage. Newest-first on the ULID primary key. One row per event per endpoint, carrying the outcome, the attempt count and the last status code or error; the per-attempt detail is on the single-delivery route. A delivery refused by the SSRF guard appears here as a failure with the reason, never as a silent success.
| workspaceId 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 .. 100 ] Default: 25 |
| endpoint_id | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: endpoint_id=01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| status | string Enum: "pending" "succeeded" "failed" |
{- "data": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "endpoint_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "idempotency_key": "string",
- "event_name": "string",
- "payload_version": "string",
- "status": "pending",
- "attempt_count": 0,
- "max_attempts": 0,
- "last_status_code": 0,
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "redelivery_of": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "next_cursor": "string"
}
}ATH-251 — requires webhooks.manage. body is the verbatim bytes that were signed and sent, so a customer debugging a signature mismatch can compare against what their server received. Each attempt carries its status code, duration and error.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| deliveryId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "endpoint_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "idempotency_key": "string",
- "event_name": "string",
- "payload_version": "string",
- "status": "pending",
- "attempt_count": 0,
- "max_attempts": 0,
- "last_status_code": 0,
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "redelivery_of": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "body": "string",
- "attempts": [
- {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "attempt": 0,
- "ok": true,
- "status_code": 0,
- "duration_ms": 0,
- "error": "string",
- "attempted_at": "2019-08-24T14:15:22Z"
}
]
}
}ATH-251 — requires webhooks.manage. Creates a NEW delivery that replays the original's exact bytes; the original keeps its status and attempt history, because that history is the evidence behind an auto-disable and must not be erased by a retry click.
The replay carries the ORIGINAL Idempotency-Key, so a receiver that already processed the event will correctly ignore it — redelivery exists for the case where the endpoint never got it.
409 when the endpoint is disabled or the delivery is still being attempted. Re-enabling is a separate, explicit PATCH.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| deliveryId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| Idempotency-Key | string <= 255 characters |
{- "data": {
- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "endpoint_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "idempotency_key": "string",
- "event_name": "string",
- "payload_version": "string",
- "status": "pending",
- "attempt_count": 0,
- "max_attempts": 0,
- "last_status_code": 0,
- "last_error": "string",
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "redelivery_of": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}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": { }
}
}ATH-260 — mints an AuthnRequest, persists its id server-side, and redirects to the IdP with a RelayState handle.
The pending request is stored in the DATABASE, not the session. A browser returning from the IdP arrives on a cross-site POST, which does not carry SameSite=Lax cookies, so a session-backed request store would break exactly the flow it exists to secure and invite somebody to "fix" it by dropping the InResponseTo check. RelayState carries an opaque single-use handle; the ACS resolves it to the stored request id and hands THAT to the library, which then asserts it against the signed document.
| 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, or refuses.
No Idempotency-Key: the caller is an IdP's browser redirect, not an Cairn client, and this endpoint's replay semantics are the opposite of replay-safe. A repeated assertion MUST be refused, never served a cached success.
EVERY one of the following must hold, and each has a test that breaks the guard and confirms the refusal turns red:
the XML signature verifies against the certificate stored for THIS workspace; NotBefore and NotOnOrAfter bracket the current time; Audience equals this workspace's SP entity id; Issuer equals this workspace's configured IdP entity id; for SP-initiated flows InResponseTo matches a pending request this workspace issued and has not consumed; for IdP-initiated flows the connection must opt in AND the response must carry no InResponseTo at all; the assertion ID has not been seen before for this workspace; and the subject resolves to a member of THIS workspace, or the connection permits just-in-time provisioning.
Every refusal is the same 403 with the same body. Somebody probing an ACS should not be able to tell an unknown workspace from a bad signature from a replayed assertion; the real reason goes to the workspace's audit log.
| 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 — requires settings.security. The IdP certificate is never returned; a SHA-256 fingerprint stands in for it, so an admin can confirm WHICH certificate is loaded without the response becoming a copy of it.
| 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": {
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "idp_entity_id": "string",
- "sp_entity_id": "string",
- "certificate_fingerprint": "string",
- "certificate_not_after": "2019-08-24T14:15:22Z",
- "enforced": true,
- "allow_idp_initiated": true,
- "jit_provisioning": true,
- "default_role": "string",
- "last_successful_login_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-260 — requires settings.security. The certificate goes to the encrypted vault (CLAUDE.md rule 10) and never comes back.
enforced is the lockout-relevant field and it is NOT freely settable. It may only be turned on once this connection has completed at least one successful login, which is recorded on the row. Enforcement is a promise that the IdP works, and the only evidence that an IdP works is that somebody used it.
Two further guards make total lockout unreachable: workspace owners are never subject to enforcement, and the last remaining owner can never be deprovisioned by SCIM. See deprovisionScimUser.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| idp_entity_id required | string <= 1024 characters |
| idp_sso_url required | string <uri> <= 2048 characters |
| idp_certificate required | string The IdP's X.509 signing certificate, PEM or bare base64. Write-only — it is stored in the encrypted vault and never read back. Validated as a parseable certificate at write time, because the alternative is discovering it was unparseable during somebody's first login attempt. |
| idp_slo_url | string or null <uri> <= 2048 characters |
| enforced | boolean Default: false |
| allow_idp_initiated | boolean Default: false |
| jit_provisioning | boolean Default: false |
| default_role | string Default: "viewer" |
{- "idp_entity_id": "string",
- "idp_certificate": "string",
- "enforced": false,
- "allow_idp_initiated": false,
- "jit_provisioning": false,
- "default_role": "viewer"
}{- "data": {
- "workspace_id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "idp_entity_id": "string",
- "sp_entity_id": "string",
- "certificate_fingerprint": "string",
- "certificate_not_after": "2019-08-24T14:15:22Z",
- "enforced": true,
- "allow_idp_initiated": true,
- "jit_provisioning": true,
- "default_role": "string",
- "last_successful_login_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}ATH-260 — requires settings.security. Deleting the connection also drops enforcement, the vaulted certificate and every pending auth request. Members fall back to password login immediately; this is the deliberate manual half of the lockout escape hatch.
| 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": { }
}
}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 — requires settings.security. Tokens are stored as a SHA-256 digest, exactly like ATH-250 API keys and ATH-252 client secrets. The plaintext existed once, in the create response, and is not recoverable here or anywhere else; the short non-secret prefix is what lets an admin tell two tokens apart.
| 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",
- "token_prefix": "string",
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}ATH-260 — requires settings.security. The response is the only place the plaintext token ever exists outside the IdP.
| 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 <= 120 characters |
| expires_at | string or null <date-time> |
{- "name": "string",
- "expires_at": "2019-08-24T14:15:22Z"
}{- "id": "01j8me9fycv0q4t4c7wz8k2xt1",
- "name": "string",
- "token_prefix": "string",
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "token": "string"
}ATH-260 — requires settings.security. Stamps revoked_at rather than deleting the row, so the audit trail survives the revocation. Idempotent — revoking twice keeps the first timestamp.
| workspaceId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
| tokenId required | string (Ulid) ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ Examples: 01j8me9fycv0q4t4c7wz8k2xt1 Crockford base32 ULID; Laravel emits lowercase, both cases accepted |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { }
}
}ATH-260 — requires settings.security. Roles come from the existing RBAC catalog; owner is not assignable from a directory group.
| 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": [
- {
- "external_group": "string",
- "role": "string"
}
]
}ATH-260 — requires settings.security. Whole-set replace rather than per-item CRUD: the mapping table is small, an admin reasons about it as one policy, and a partial update is how you end up with a stale row nobody remembers granting.
role must be a key of the RBAC catalog and may not be owner. A directory group conferring billing.manage and workspace.delete is not a mapping anyone should be able to make by typing a group name.
| 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 |
required | Array of objects (ScimGroupMapping) <= 100 items |
{- "mappings": [
- {
- "external_group": "string",
- "role": "string"
}
]
}{- "data": [
- {
- "external_group": "string",
- "role": "string"
}
]
}ATH-260 — what this SCIM implementation supports. IdPs read it during connector setup to decide whether to send PATCH or PUT and whether to attempt filtering.
security: [] means only that this path does not use the house bearer scheme. It still requires a workspace SCIM bearer token, like every other path under /scim/v2.
| 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"
}