Skip to content

HTTP API ​

Base path /api/v1. JSON in, JSON out.

Authentication ​

CredentialHeaderWho holds it
Secret API key — cls_live_…, cls_test_…Authorization: Bearer <key>your backend only
Session token — minted per session and roleAuthorization: Bearer <token> or x-session-token: <token>the actor's browser
Callback token — per async action callAuthorization: Bearer <token>the provider that answers a webhook

An API key belongs to one environment and sees only its sessions. cls_live_… keys belong to production: their sessions are live sessions, the ones your plan counts each month. cls_test_… keys belong to test environments, which are free and unlimited; there, email and one-time codes reach only your workspace's registered address. A session token is good for one session as one role; the engine takes the actor from it, never from the request body.

Your backend (API key) ​

POST /workflows/deploy ​

json
{ "workflowId": "expense_claim_v1", "version": "1.0.0", "manifest": { … }, "draft": false }

Creates or updates the workflow in the key's environment: new sessions there start on this manifest, and other environments keep what they run. Every distinct manifest is kept as a revision; running sessions stay on theirs. clipless deploy calls this. The answer names the environment. 422 with problems when the manifest cannot run as declared — a role's conditions reading answers it may not see, a transition to a stage that does not exist, a placeholder naming nothing.

POST /workflows/:workflowId/sessions ​

Start a session, on the revision the key's environment runs. 404 when the workflow was never deployed to that environment.

Body
rolethe first actor's role
externalUserIdyour id for the person
prefill{ fieldId: value } to start with; unknown field ids → 422
periodwhich run of a recurring workflow this is — see Recurring. Needs externalUserId
prefillFrom"previous" or a session id: start from that user's earlier run
timeZoneIANA time zone that business-day deadlines are counted in; default UTC
holidaysdays off besides weekends, as local dates: ["2026-12-25"]
participantswho plays each role: { "role:applicant": "[email protected]" }. A role no stage owns → 422
expiresInhow long the token lasts, in seconds: default a day, at most 30 days
json
{
  "session": "Xk2…",
  "token": "…",
  "expiresAt": "…",
  "created": true,
  "participants": [ { "role": "role:applicant", "email": "[email protected]" } ],
  "carried": { "from": "…", "fields": [ … ], "dropped": [ { "field": "attest", "reason": "not carried" } ] },
  "state": { … }
}

With period, asking again for the same user and period returns the existing session with "created": false and a fresh token, and keeps its participants (change them with the route below). carried is present when prefillFrom found a run.

PUT /sessions/:sid/participants/:role ​

Set who plays a role, or hand it to someone else. Body { "email": "…" }; the role must own a stage of the workflow (else 422). Answers { "role", "email" }. The session's detail lists its participants. Handing the role to a different address stops everything the last person held: their emailed links and every token minted for that role before now (401). Mint the new person a token after the change.

POST /sessions/:sid/tokens ​

Mint a token for a role: the handoff. See Getting started.

Body
rolea role that owns a stage of this workflow (else 422)
externalUserIdwho the person is in your app; carried in the token, not checked
expiresInseconds, default a day, at most 30 days
json
{ "token": "…", "expiresAt": "…", "yourTurn": true, "currentStageId": "review", "status": "in_progress" }

yourTurn is true when the current stage belongs to this role and is still open. Tokens expire. An expired one gets 401, and your backend mints a new one. Mint them when the person opens your page, not ahead of time.

GET /workflows/:workflowId/sessions ​

What is in flight, newest first. Filters: ?status=, ?externalUserId=, ?period=, ?stage= (on this stage), ?role= (on a stage this role owns), ?limit= (max 200).

?role=role:manager&status=in_progress is what is waiting on managers: each row then carries yourTurn. Narrow it by your own data (which employees report to this manager).

json
{ "workflow": "expense_claim_v1", "sessions": [
  { "session": "Xk2…", "status": "in_progress", "externalUserId": "user_42", "period": null,
    "currentStageId": "review", "currentRole": "role:manager", "events": 14, "version": "1.0.0", "startedAt": "…", "updatedAt": "…" }
] }

GET /workflows/:workflowId/sessions/:sid ​

One session in full: replayed state (vault decrypted), the whole ledger, and sensitive — the ids of pii and vault fields, for tools that mask them.

GET /workflows/:workflowId/sessions/:sid/documents/:docId ​

A document an action produced (PDF).

GET /workflows/:workflowId/sessions/:sid/uploads/:pid ​

A stored file: { pid, name, type, size, sha256, url, expiresIn }, url a presigned GET.

Secrets ​

GET /secretsthe environment's secret names and the signing secret's prefix
PUT /secrets/:NAME { "value": "…" }set one (UPPER_SNAKE_CASE)
DELETE /secrets/:NAMEremove one
POST /secrets/signingcreate or rotate the webhook signing secret; returned once

STORAGE_BUCKET, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY (and optionally STORAGE_ENDPOINT, STORAGE_REGION) connect the environment's bucket for file uploads.

Secret API keys only. Values are never returned.

The actor's browser (session token) ​

<Workflow runtimeToken> uses these; you rarely call them yourself.

Route
GET /sessions/:sidthe state, as this role may see it
GET /sessions/:sid/eventsthe ledger, redacted for this role → { events, head, status }
GET /sessions/:sid/outlineevery stage with the fields this role may read
GET /sessions/:sid/manifestthis role's stages only, with actions reduced to id, type, blocking: enough to render the workflow with <ManifestWorkflow> when the page has no source file
POST /sessions/:sid/events{ events: [{ type: 'field', field, value }, …] } → { state, accepted, rejected, head }
POST /sessions/:sid/submitvalidate and submit the active stage
POST /sessions/:sid/actions/:actionId/runask for a field's action → { ok, cached, waitingOn, emitted, state, head }; cached when it already ran on this value; { "retry": true } reruns a failure; 403 if the stage is not this role's, 409 if not now (see below)
GET /sessions/:sid/documents/:docIda generated document
POST /sessions/:sid/uploads{ field, name, type, size } → { upload, url, method, headers, expiresIn }: a presigned PUT to your bucket. 409 without a bucket, 422 if the file does not fit the field
POST /sessions/:sid/uploads/:pid/completethe file is in the bucket: the engine hashes it and records it → { ok, file, emitted, state, head }; 409 if nothing arrived
GET /sessions/:sid/uploads/:pida short-lived link, for a role that may read the field
WS /sessions/:sid/livefirst message { "type": "auth", "token": "…" }; then { head, status } whenever the session moves; closed with 4401 when the token expires

Submit answers 200 with { ok, completed, waitingOn, emitted, state, head } — emitted is what the engine did (actions, routing), waitingOn an action the session is now parked on. 422 with errors: [{ fieldId, message }] when the stage does not validate, or with blockedBy and reason when a blocking action refused. 403 when the stage is not this role's; 409 when it is but not now — it is locked, waiting on an action, expired, or the workflow is complete. The field-action and upload routes answer the same way.

Events other than answers and navigation are refused: stage and action events are the engine's. A file field accepts only a value with files removed — files are added by upload.

What the hosted page calls with the token from an emailed link (an email action sent to a role).

Route
POST /links/:tokena session token for the participant's role, good for a day: { session, role, token, expiresAt, workflow, workspace: { name, slug }, status }. 404 for a token never issued; 410 for an expired link (renewable: true) or a role handed to someone else (renewable: false)
POST /links/:token/renewemails a new link to the address that plays the role now, never one typed in; at most one every 5 minutes per participant. Always { ok: true }

A link works as often as it is opened until it expires (7 days) or the role is reassigned: mail scanners open links before people do.

Providers ​

RouteAuth
POST /sessions/:sid/actions/:actionId/callbackcallback token — { ref, result } or { ref, error } for a webhook that answered 202
POST /providers/stripe-identity/:environmentPid/webhookStripe's signature