HTTP API
Base path /api/v1. JSON in, JSON out.
Authentication
| Credential | Header | Who holds it |
|---|---|---|
Secret API key — cls_live_…, cls_test_… | Authorization: Bearer <key> | your backend only |
| Session token — minted per session and role | Authorization: Bearer <token> or x-session-token: <token> | the actor's browser |
| Callback token — per async action call | Authorization: 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 | |
|---|---|
role | the first actor's role |
externalUserId | your id for the person |
prefill | { fieldId: value } to start with; unknown field ids → 422 |
period | which run of a recurring workflow this is — see Recurring. Needs externalUserId |
prefillFrom | "previous" or a session id: start from that user's earlier run |
timeZone | IANA time zone that business-day deadlines are counted in; default UTC |
holidays | days off besides weekends, as local dates: ["2026-12-25"] |
participants | who plays each role: { "role:applicant": "[email protected]" }. A role no stage owns → 422 |
expiresIn | how 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 | |
|---|---|
role | a role that owns a stage of this workflow (else 422) |
externalUserId | who the person is in your app; carried in the token, not checked |
expiresIn | seconds, 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 /secrets | the environment's secret names and the signing secret's prefix |
PUT /secrets/:NAME { "value": "…" } | set one (UPPER_SNAKE_CASE) |
DELETE /secrets/:NAME | remove one |
POST /secrets/signing | create 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/:sid | the state, as this role may see it |
GET /sessions/:sid/events | the ledger, redacted for this role → { events, head, status } |
GET /sessions/:sid/outline | every stage with the fields this role may read |
GET /sessions/:sid/manifest | this 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/submit | validate and submit the active stage |
POST /sessions/:sid/actions/:actionId/run | ask 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/:docId | a 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/complete | the 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/:pid | a short-lived link, for a role that may read the field |
WS /sessions/:sid/live | first 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.
Participant links (no auth)
What the hosted page calls with the token from an emailed link (an email action sent to a role).
| Route | |
|---|---|
POST /links/:token | a 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/renew | emails 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
| Route | Auth |
|---|---|
POST /sessions/:sid/actions/:actionId/callback | callback token — { ref, result } or { ref, error } for a webhook that answered 202 |
POST /providers/stripe-identity/:environmentPid/webhook | Stripe's signature |