REST API
Programmatic access to sol2flow with personal API keys. A key acts as the person who created it, with exactly their permissions — the API is another way in with the same permission checks as the app, never a way around them.
- Base URL:
<APP_URL>/api/v1 - Reference: every instance has its own Swagger UI at
<APP_URL>/api/docsand the OpenAPI 3.1 document at<APP_URL>/api/v1/openapi.json— always matching the instance's version (an administrator can switch them off, see Instance settings). In the Swagger UI, Authorize takes an API key and Try it out calls the API from your browser. - This page explains the concepts; the Swagger UI lists every endpoint with its parameters and response schemas.
- To use sol2flow from an AI assistant (Claude, Cursor, VS Code, …), see the MCP server.
Authentication
- In the app: account menu (bottom left) → API keys → Create API key. Give it a name, an expiry (30 days,
90 days (default), 1 year, never) and its access: Full access (default) or Read only (Scopes). Creating a key needs a sign-in within the last 10 minutes (else "Confirm it's
you"). The key (
sf_<prefix>_<secret>) is shown once — copy it into your secret store. - Send it on every request:
curl -H "Authorization: Bearer $SOL2FLOW_KEY" https://flow.example.com/api/v1/me
GET /me returns staff_role ("OWNER", "ADMIN", "SUPPORT", "AUDITOR" or null) instead of the boolean
is_instance_admin, which is gone. Scripts that checked is_instance_admin should check staff_role !== null — or
the specific role.
- Only the
Authorization: Bearerheader is accepted. Cookies are ignored: a request with just the browser's session cookie gets 401. So there is no CSRF surface, and a script can't borrow a browser session. - A key stops working when it expires, when it's revoked (by you, or by an Owner / Admin of the instance), when your account is
disabled or deleted, while your account can't sign in (for example a sign-up whose email address isn't confirmed yet),
and while the instance has API keys switched off (403
api_disabled). - Up to 25 active keys per account. Every key creation and revocation sends a security email to the account.
- The list shows each key's name,
sf_<prefix>…<last 4>, its access (Full access / Read only), created, expiry and last use (updated at most once a minute).
Scopes
A key has full access (everything its owner may do — the default, and every key made before scopes existed) or is read only:
- A read-only key may call GET and HEAD only — listing, reading, searching, downloading attachments
(
GET /files/{id}). Every other method answers 403insufficient_scope, before anything else happens. - Checked centrally for every endpoint.
- Some reads need full access too: the webhook endpoints (their URLs and delivery logs are admin configuration).
- The OpenAPI document names the scope every operation needs in
x-required-scope(read/full), and the bearer scheme's description explains the rule. - A scope never widens anything: a read-only key still only sees what its owner may see.
Conventions
- JSON in and out, snake_case. Timestamps are ISO 8601 in UTC (
2026-09-25T09:30:00.000Z); calendar days areYYYY-MM-DD. Ids are UUIDs; workspaces are addressed by their slug (as in/w/<slug>). - Success:
200with the resource,201for creations,202for invitations (sent by email),204(no body) for deletes. Writes return the updated resource, so you rarely need a second GET. - People are
{ id, name, username }— never their email (onlyGET /mereturns your own), like the app's pickers. Exception, as in the app: staff who see emails (Owner, Admin, Support — Staff roles) getemailon members and board people; invitation addresses follow the masking rule below. - Rich text (task descriptions, comments) is accepted as plain text (one paragraph per line) or as a Tiptap JSON
document (
{"type":"doc","content":[…]}, the editor's format). Both are sanitized exactly like input from the app; mentions ({"type":"mention","attrs":{"id":"<user id>","label":"Name"}}) only survive for people who can open the board, and they are notified. Responses give the JSON (description,doc) and the plain text (description_text,text). Markdown works both ways: send"format": "markdown"with a string, and add?format=markdownto get it back — see Markdown. - Positions: lists, tasks and checklist items move between neighbours —
previous_id(the item that ends up directly before) andnext_id(directly after). Moving a task without either puts it at the bottom of the list. - Writes create the same activity entries and notifications as the app, with you as the actor.
Pagination
List endpoints take limit (1–100, default 50) and cursor, and answer
{ "data": [ … ], "next_cursor": "eyJ1Ijoi…" }
Pass next_cursor back as cursor until it is null. Cursors are opaque (don't build them); a malformed one is a 400.
GET /tasks is ordered by last change, oldest first, so updated_since=<timestamp> plus the cursor gives an
incremental sync.
Errors
{ "error": { "code": "title_required", "message": "Title is required.", "field": "title" } }
code is stable — branch on it; message is English for humans; field names the input when known; max is the
limit for too_long / file_too_large / api_key_limit; retry_after (seconds) comes with 429; plan_limit comes with
kind (what ran out, e.g. seats or guests), limit and used.
| Status | When | Typical codes |
|---|---|---|
| 400 | Body isn't JSON; a path or query parameter is invalid (bad UUID, bad cursor) | invalid_input |
| 401 | No key, malformed, unknown, expired or revoked key; account disabled | unauthenticated |
| 403 | Your role doesn't allow it; the key is read-only; API keys, webhooks or public links switched off; not in your plan | forbidden, insufficient_scope, api_disabled, webhooks_disabled, public_links_disabled_workspace, plan_feature, guest_viewer_only |
| 404 | Doesn't exist or you can't open it (the API never says which) | not_found |
| 405 | Known path, other method | method_not_allowed |
| 409 | The current state doesn't allow it | board_archived, label_scope, last_board_admin, last_admin_leave, last_private_admin, oidc_managed, instance_admin_leave, conflict, upload_incomplete, webhook_limit, plan_limit |
| 413 | File (or JSON body > 1 MB) too large | file_too_large |
| 422 | Validation failed | title_required, name_required, too_long, dates_order, duration_invalid, invalid_input, webhook_url_blocked, webhook_url_unresolved, webhook_url_invalid, events_required, … |
| 429 | Rate limit — wait Retry-After seconds | rate_limited |
| 500 | A bug; nothing internal is leaked | internal |
Rate limits
Per key: 600 requests / 10 minutes, of which at most 120 writes (POST, PUT, PATCH, DELETE). Requests without a
valid key are limited per client IP (30 / 10 minutes; then 429 instead of 401). API calls also count against the same
per-user limits as the app (comments 30 / min, search 240 / min, uploads 300 / h, invitations 100 addresses / h per
inviter and 200 / h per workspace, revocations 100 / h). Over a limit: 429 with a
Retry-After header (seconds) and retry_after in the body.
Endpoints
The full, always-current reference (parameters, bodies, response schemas, examples) is your instance's Swagger UI at /api/docs. Overview:
| Area | Endpoints |
|---|---|
| Account | GET /me — your profile, including staff_role (OWNER · ADMIN · SUPPORT · AUDITOR · null) and time_zone (your saved IANA zone from Settings → Preferences, e.g. Europe/Berlin; null = automatic, the browser's; read-only — API timestamps are always UTC) and, since API 1.6.0, email_verified (true once your email address is confirmed; false without an email address) and, from API 1.8, api_key: the key used for the request (name, prefix, scope full / read, expires_at; never its secret) |
| Workspaces | GET /workspaces · GET /workspaces/{slug} · GET /workspaces/{slug}/search?q= |
| Boards | GET /workspaces/{slug}/boards (archived, q) · POST /workspaces/{slug}/boards · GET /boards/{id} (with lists) · PATCH /boards/{id} (name, access, default role) · POST /boards/{id}/archive · POST /boards/{id}/restore |
| Lists | POST /boards/{id}/lists · PATCH /boards/{id}/lists/{listId} · POST …/lists/{listId}/move · DELETE …/lists/{listId} |
| Labels | GET /workspaces/{slug}/labels · GET /boards/{id}/labels · POST /boards/{id}/labels |
| Tasks | GET /tasks (board_id or workspace; list_id, assignee, label, q, due_from, due_to, updated_since, archived=true to include archived tasks) · GET /tasks/{id} · POST /tasks · PATCH /tasks/{id} · POST /tasks/{id}/move · POST /tasks/{id}/move-board (board_id, list_id, position: another board of the same workspace; new key, the old one redirects in the app) · POST /tasks/{id}/archive · tasks: estimate_minutes + estimate_display (working time, Accept-Language), write estimate: "3d" or estimate_minutes · POST /tasks/{id}/unarchive (an archived task is read-only: 409 task_archived) · DELETE /tasks/{id} · POST /tasks/{id}/restore · PUT /tasks/{id}/labels · PUT/POST /tasks/{id}/assignees, DELETE /tasks/{id}/assignees/{userId} · same for members |
| Checklist | POST /tasks/{id}/checklist · PATCH /checklist/{itemId} (text, done) · POST /checklist/{itemId}/move · DELETE /checklist/{itemId} |
| Task links | GET /tasks/{id}/links (only links to tasks you can open) · POST /tasks/{id}/links (kind: relates_to, blocks, blocked_by, duplicates, duplicated_by — seen from this task; task_id: the other task, same workspace; 409 link_exists, 422 link_self) · DELETE /tasks/{id}/links/{linkId} |
| Comments | GET /tasks/{id}/comments (with replies; `order=newest |
| Time | GET /time-entries?workspace= (board_id, task_id, user_id, from, to) · POST /time-entries · PATCH /time-entries/{id} · DELETE /time-entries/{id} · GET /timer · POST /timer/start · POST /timer/stop |
| Attachments | GET /tasks/{id}/attachments · GET /files/{fileId} · uploads, below |
| Notifications | GET /notifications (unread) · PATCH /notifications/{id} (read) · POST /notifications/read-all |
| Sharing | GET /boards/{id}/sharing · PATCH /boards/{id}/sharing (access, default role, public link) · POST /boards/{id}/members · PATCH/DELETE /boards/{id}/members/{userId} · GET /boards/{id}/invitations |
| Members | GET /workspaces/{slug}/members (role, q, include=invitations) · POST /workspaces/{slug}/members · PATCH/DELETE /workspaces/{slug}/members/{userId} · POST /workspaces/{slug}/leave |
| Invitations | POST /workspaces/{slug}/invitations (202) · GET /workspaces/{slug}/invitations · DELETE /invitations/{id} · GET /me/invitations · POST /me/invitations/{id}/accept · POST /me/invitations/{id}/decline |
| Webhooks | GET/POST /workspaces/{slug}/webhooks · GET/PATCH/DELETE …/webhooks/{id} · POST …/{id}/rotate-secret · POST …/{id}/ping (202) · GET …/{id}/deliveries (status) · GET …/deliveries/{deliveryId} · POST …/deliveries/{deliveryId}/redeliver (202) |
Time entries: started_at is an instant; the start is kept to the minute, like entries logged in the app. Give either
duration_sec (60 … 86 400) or ended_at. Visibility follows the app's rules (Time tracking).
Guests get 404 for GET /time-entries, as they have no timesheets.
Comments: guests can use the comment endpoints (write, reply, edit and delete their own) only where guest comments are on; otherwise 403.
Sharing, members and invitations
The same rules as the Share dialog, the Members page and the invite banner (Members and sharing).
| Endpoint | Who | Notes |
|---|---|---|
GET /boards/{id}/sharing | anyone who can open the board | access, default role, public_link.enabled; for board admins also public_link.url, expires_at, password (true when one is set), visibility, allowed and blocked_by (plan or workspace). people: explicit board members and the workspace admins (locked), guests flagged |
PATCH /boards/{id}/sharing | board admins | access, default_role, public_link: true creates / keeps the link, false removes it, or an object with the settings (below) |
POST /boards/{id}/members | board admins | {user_id, role?, workspace_role?}; without role: VIEWER for guests, EDITOR for everyone else. Guests can't get more than VIEWER (403 guest_viewer_only). Someone in the workspace, or an account outside it that you could pick in the app (shares a workspace with you) — added to the workspace as workspace_role (GUEST; MEMBER needs a workspace admin). Anyone else: 404, invite by email |
PATCH / DELETE /boards/{id}/members/{userId} | board admins | only explicit members (else 404); the last board admin stays (409 last_board_admin); a guest can't get more than VIEWER (403 guest_viewer_only) |
GET /workspaces/{slug}/members | admins and members of the workspace (guests: 404) | cursor-paginated, admins first; role, q, include=invitations (then the pending invitations follow). managed = OIDC group sync (read-only), automatic = instance-admin grant |
POST /workspaces/{slug}/members | workspace admins | {user_ids, role}: only people you can see (shared workspace; Owner / Admin staff: everyone); the rest is skipped → {added} |
PATCH / DELETE /workspaces/{slug}/members/{userId} | workspace admins | not yourself (403, use leave), not OIDC-managed rows (403), not the last explicit admin of a private workspace (409 last_private_admin) |
POST /workspaces/{slug}/leave | any member | 409 last_admin_leave, last_private_admin, oidc_managed, instance_admin_leave (rules) |
POST /workspaces/{slug}/invitations | workspace admins; board admins for guests with board_id | {emails, role, board_id?, board_role?} → 202 {sent}. Guests get VIEWER at most (EDITOR / ADMIN: 403 guest_viewer_only); over the plan's guest limit: 409 plan_limit with kind guests |
GET /workspaces/{slug}/invitations, GET /boards/{id}/invitations | admins and members of the workspace (guests: 404) / anyone on the board | pending invitations with can_revoke, one item per invitation (someone invited to the workspace and to two boards is three items) |
DELETE /invitations/{id} | workspace admins, whoever sent it, admins of its board | 404 when it isn't pending |
GET /me/invitations, POST /me/invitations/{id}/accept, …/decline | you | matched by your verified email only; accept and decline answer that one invitation only (accepting joins the workspace if needed and adds that invitation's board; your other invitations stay pending); accept returns the workspace |
The public link's settings (Share a board publicly) go in a public_link object; every
field is optional:
| Field | Meaning |
|---|---|
enabled | true / false: turn the link on or off |
expires_at | a future date and time (ISO 8601 with an offset), or null for no expiry; a past one is 422 expiry_past |
password | 6–128 characters, or null to remove it; a new or removed password signs out every visitor. Write-only: it is never returned |
regenerate | true: a new link; the old one stops working at once |
visibility | what visitors see, e.g. {"assignees": true, "time": false}; keys you leave out stay as they are. Keys: labels, dates, checklists, descriptions (on for new links), assignees, members, comments, attachments, time (off) |
Anything but turning a link off is refused with 403 plan_feature when the plan doesn't include public links, and 403
public_links_disabled_workspace when the workspace has Allow public links off.
Privacy, exactly like the app:
- Emails: other people's addresses only for staff who see emails (
email: nullotherwise). Invitation addresses are shown in full only to those staff, workspace admins and whoever sent the invitation; everyone else getsj•••@client.io(email_masked: true), andqonly matches what you see, so it can't be used to probe an address. - Accounts: inviting answers the same
202 {"sent": n}whether or not an address has an account — nobody is added directly, every address gets the email (and an in-app notification if a verified account has it).POST /boards/{id}/membersandPOST /workspaces/{slug}/membersonly reach people you could already pick in the app. - Rate limits: invitations count against
invite(per inviter) andinvite-workspace(per workspace), each address once; revoking againstinvite-revoke— the same buckets as the app, on top ofapi/api-write.
curl -H "$H" -H 'Content-Type: application/json' -X PATCH "$BASE/boards/$BOARD/sharing" -d '{"access":"RESTRICTED","public_link":false}'
curl -H "$H" -H 'Content-Type: application/json' -X PATCH "$BASE/boards/$BOARD/sharing" \
-d '{"public_link":{"enabled":true,"expires_at":"2026-12-31T23:59:59Z","visibility":{"assignees":true}}}'
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/boards/$BOARD/members" -d '{"user_id":"…","role":"EDITOR"}'
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/workspaces/northwind/invitations" \
-d '{"emails":["[email protected]"],"role":"GUEST","board_id":"…","board_role":"VIEWER"}' # → 202 {"sent":1}
curl -H "$H" "$BASE/workspaces/northwind/members?include=invitations&limit=100"
curl -H "$H" -X POST "$BASE/me/invitations/$INVITE/accept"
Plans
Where plans apply, GET /api/v1/public/plans (no key needed) lists the public plans, and GET /organizations/{slug}/plan
shows an organization's plan, limits and usage to its owners and admins.
featuressays what a plan includes, among othersguest_comments(workspaces can turn on Guests can comment) andemail_notifications(off: notifications from the organization's workspaces are in the app only).limits.guestsis the optional guest limit (null= unlimited). Guests never take a seat.- Since API 1.5.0,
guests_freeandlimits.guests_freeare deprecated: alwaystrue, to be removed in a later version. Don't rely on them. highlightsno longer include "Guests don't use a seat".
Webhooks
Workspace webhooks (Webhooks: events, payloads, signatures, retries, address rules) over the API — the
same service as Settings → Workspace → Webhooks. Workspace admins with a full-access key only, also for GET
(read-only keys: 403 insufficient_scope; other members: 403 forbidden); 403 webhooks_disabled while the instance
has them off.
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/workspaces/northwind/webhooks" \
-d '{"url":"https://hooks.example.com/sol2flow","description":"CI","events":["task.created","comment.created"]}'
# → 201 {"id":"…","state":"active",…,"secret":"whsec_…"} (the secret is shown only here and by rotate-secret)
curl -H "$H" -H 'Content-Type: application/json' -X PATCH "$BASE/workspaces/northwind/webhooks/$HOOK" -d '{"active":false}'
curl -H "$H" -X POST "$BASE/workspaces/northwind/webhooks/$HOOK/ping" # → 202, the queued delivery
curl -H "$H" "$BASE/workspaces/northwind/webhooks/$HOOK/deliveries?status=failed"
curl -H "$H" -X POST "$BASE/workspaces/northwind/webhooks/$HOOK/deliveries/$D/redeliver"
ping and redeliver count against webhook-manual (60 / 10 minutes per workspace).
Imports
Board imports from kan.bn and Trello (Imports: what is imported and how) — the same as Settings → Workspace → Import & export. Workspace admins only: POST needs a
full-access key, GET a read key. The run is a background job: POST answers 202 with the import, poll GET until
status is DONE or FAILED. One import per workspace at a time (409 import_running). Source credentials travel in
the body once, are kept encrypted only while the run needs them, and are never returned.
# Trello API (key + token) or kan.bn (api_key, base_url for a self-hosted Kan); boards = source board ids
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/workspaces/northwind/imports" \
-d '{"source":"trello","api_key":"…","token":"…","boards":["5f1b…"],"access":"RESTRICTED"}' # → 202 {"id":"…","status":"PENDING",…}
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/workspaces/northwind/imports" \
-d '{"source":"kanbn","base_url":"https://kan.example.com","api_key":"…","boards":["brd8Kq2mZx1a"],"people":{"mem1aB2cD3eF":"<user id>"}}'
# a Trello board JSON export (up to 50 MB) in `board`
jq -n --slurpfile b board.json '{source:"trello_file", board:$b[0]}' | curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/workspaces/northwind/imports" -d @-
curl -H "$H" "$BASE/workspaces/northwind/imports/$IMPORT" # status, counts, errors, results[].board_key
people maps source person keys to workspace members (null = leave unassigned); people not listed are matched
automatically (email → username → unique full name). Nobody is invited. Re-running an import adds only what is new.
Uploading an attachment
The same chunked, resumable upload as the app:
# 1. start: the answer says how to split the file
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"board_id":"…","task_id":"…","name":"report.pdf","size":1048576,"mime_type":"application/pdf"}' \
$BASE/uploads # → {"upload_id":"…","chunk_size":8388608,"part_count":1,"size":1048576}
# 2. each part n = 1…part_count: exactly chunk_size bytes (the last one: the rest)
curl -X PUT -H "Authorization: Bearer $KEY" -H 'Content-Type: application/octet-stream' \
--data-binary @report.pdf $BASE/uploads/$UPLOAD_ID/parts/1
# 3. finish: the file is attached to the task
curl -X POST -H "Authorization: Bearer $KEY" $BASE/uploads/$UPLOAD_ID/complete
GET /uploads/{id} lists the parts that arrived (resume), DELETE /uploads/{id} cancels. Size limits: Admin → General →
Uploads (413 file_too_large, max in MB).
Markdown
Task descriptions, comments and replies can be written and read as Markdown (GitHub-flavoured) — the same conversion the editor uses when Markdown is pasted, followed by the same cleaning as every other input.
- Writing:
POST /tasks,PATCH /tasks/{id}(description) andPOST /tasks/{id}/comments,PATCH /comments/{id}(text) take"format": "markdown". Without it ("text", the default) a string is plain text, one paragraph per line — existing clients are unaffected. A Tiptap JSONdescription/docignoresformat. - Reading:
?format=markdownonGET /tasks/{id},POST /tasks,PATCH /tasks/{id},GET /tasks/{id}/comments(replies too),POST /tasks/{id}/commentsandPATCH /comments/{id}addsdescription_markdown(tasks;nullwithout a description) ormarkdown(comments;nullwhen deleted).description/docand the plain text stay as they are. - Syntax:
#/##→ large heading,###+ → heading;**bold**,*italic*/_italic_,~~strike~~,`code`, fenced code blocks with a language,>quotes,-/*/1.lists (nested),- [ ]/- [x]task lists,---, links and autolinks, line breaks kept (a single newline is a line break). Tables become one paragraph per row. - Mentions:
@[Name](user:<user id>)in and out; the same rules as JSON mentions (only people who can open the board stay mentions and are notified; anyone else becomes the text "@Name"). - Safety: raw HTML is never interpreted —
<b>,<script>,<img onerror=…>stay literal text. Links must be http(s), mailto or app-relative (javascript:etc. keep only their text); images only as/api/files/<id>(others become links). Underline has no Markdown and is dropped on output.
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/tasks?format=markdown" \
-d '{"board_id":"…","list_id":"…","title":"Release 1.2","format":"markdown","description":"## Steps\n- [ ] Changelog\n- [x] Tag **v1.2**"}'
# → { …, "description": {"type":"doc",…}, "description_text": "Steps\nChangelog\nTag v1.2", "description_markdown": "## Steps\n\n- [ ] Changelog\n- [x] Tag **v1.2**" }
curl -H "$H" "$BASE/tasks/$TASK/comments?format=markdown"
Examples
BASE=https://flow.example.com/api/v1; KEY=sf_…
H="Authorization: Bearer $KEY"
curl -H "$H" "$BASE/workspaces/northwind/boards?q=website"
curl -H "$H" "$BASE/tasks?workspace=northwind&assignee=$ME&due_to=2026-10-31&limit=100"
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/tasks" \
-d '{"board_id":"…","list_id":"…","title":"Write release notes","description":"Cover the API.\nLink the docs.","due_date":"2026-10-01"}'
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/tasks/$TASK/comments" -d '{"text":"Shipped 🎉"}'
curl -H "$H" -H 'Content-Type: application/json' -X POST "$BASE/time-entries" \
-d '{"task_id":"…","started_at":"2026-09-25T08:00:00Z","duration_sec":5400,"note":"Pairing"}'
Instance settings
Admin → General → API:
- Allow API keys (default on). Off: the "API keys" menu item disappears and every existing key gets 403
api_disabled; turning it back on restores them (they aren't deleted). - Allow webhooks (default on) and Allow webhooks to private network addresses (default off): Webhooks.
- API documentation (default on). Off:
/api/docs(Swagger UI and its assets) and/api/v1/openapi.jsonanswer 404 — e.g. for a production instance that shouldn't advertise its API. The API itself keeps working.
The Swagger UI is served by the instance itself (no external CDN) and follows the app's dark / light theme. "Authorize" takes a key; "Try it out" then calls the API from the browser with it.
Versioning
- The version is in the path (
/api/v1). Within v1 changes are additive only: new endpoints, new optional request fields, new response fields, new error codes for new situations. Clients must ignore unknown fields. - Removing or renaming a field, changing a type or a status code, or tightening validation of an existing field needs
/api/v2; v1 then keeps working for at least 6 months, announced in the changelog. info.versionin the OpenAPI document follows semantic versioning for the v1 contract.- From API 1.8, every
/api/v1answer carries the headerSol2flow-Api-Version(for example1.8.0), the same version asinfo.version, so a client can tell what the instance supports even when the API documentation is switched off. Older instances send no such header.