API and integrations
OpenAPI and SDKs
Every instance describes its own API as an OpenAPI 3.1 document:
7 min read
GET /api/openapi.json– the document. Itsserversentry carries the instance's address (APP_URL)./api/docs– the same description for reading: every path by section, parameters, requests, answers and schemas.
Both are only available to signed-in people and with an API token (Authorization: Bearer fp_…, any scope); without signing in /api/openapi.json answers 401 and /api/docs asks to sign in. Instances that want to show the description publicly set FLOWPLAN_PUBLIC_API_DOCS=true; visitors then only see the major version (0.x) under info.version, signed-in people the exact one.
curl -H "Authorization: Bearer fp_…" https://flowplan.example.com/api/openapi.json > openapi.jsonIt lists every endpoint an API token may call: workspace and pages, search, files, imports and exports, webhooks, the MCP server and POST /api/command with a schema for each commonly used action (page.create, row.create, row.update, webhook.delete …) plus the list of all other actions. Each endpoint names its error codes (400, 401, 403, 404, 409, 413, 429) and the rate limits; calendar subscriptions and CalDAV are noted as external accesses with their own link.
Uploaded images have a placeholder: the answer of POST /api/upload names, under placeholder, the size the image is shown in (w, h) and a ThumbHash (opens in a new tab) (hash, base64) – a blurred preview in about 25 bytes. GET /api/files/placeholders?ids=… returns the same for up to 100 files and profile pictures (avatar:<user ID>), but only for files you may download; anything else is left out of the answer. For older files the placeholder is computed on the first request. Files of encrypted spaces never have one – the server cannot see their content.
The document is built from the code: where the server validates input with zod (properties and views of a database, the actions, export options), the same schemas are used; the rest is described from the routes in lib/openapi.ts. A test keeps them together – every route under app/api, every action of command() and the SDKs.
Clients
Two thin, dependency-free clients are generated from the document, in the source under sdk/ (opens in a new tab):
- TypeScript (
sdk/typescript/flowplan.ts): one file, ESM, built onfetch, with types for every answer and action. Copy the file into your project and you are done:
import { FlowplanClient } from "./flowplan.js";
const client = new FlowplanClient({ baseUrl: "https://flowplan.example.com", token: "fp_…" });
const { workspace, pages } = await client.bootstrap();
const { id } = await client.command({ action: "row.create", pageId: "<database ID>", cells: { title: "From the SDK" } });- Python (
sdk/python/flowplan_client.py): standard library only (urllib), Python 3.11 or newer,TypedDicts for the answers:
from flowplan_client import FlowplanClient
client = FlowplanClient("https://flowplan.example.com", token="fp_…")
shell = client.bootstrap()
created = client.command({"action": "row.create", "pageId": "<database ID>", "cells": {"title": "From the SDK"}})Methods are named after the document's operationId (getPage / get_page, search, uploadFile / upload_file, markdownExport / markdown_export …). Answers outside 2xx raise FlowplanError with the status, the server's message and, for 429, the time to wait. Each SDK comes with a README and an example script.
Both clients send an Idempotency-Key with every change on their own and retry by themselves – GET requests and changes after a network error, 429, 502, 503, 504 or 409 while the first attempt still runs, twice by default (retries, 0 turns it off). Changes are retried with the same key and so applied once. To repeat an action later, pass your own key: idempotencyKey (TypeScript, in the options of a call) or idempotency_key= (Python).
If you build Flowplan yourself, npm run sdk regenerates the clients; the test tests/openapi.test.ts fails when the committed files no longer match the document.
Tags and properties via the API
GET /api/tags?workspace=<id>– the workspace's tags (id,name,color,position,pages,records: number of readable database records with the tag) and properties (id,name,type,options).GET /api/tags/<tagId>?workspace=<id>also returns the readable pages (pages) and records (records:page_id,row_id,title,database,space_id,updated_at,tags) with that tag.GET /api/pages/<id>containsproperties:tags(tag IDs),values(by property ID) andinfo(created/changed at/by).POST /api/commandwithpage.properties:{ "action": "page.properties", "pageId": "…", "addTags": ["Customer"], "values": { "Status": "Open", "Budget": 1200 } }. Tags and properties go by ID or name;tagsreplaces all tags,addTags/removeTagschange them,nullremoves a value.POST /api/commandwithpage.collapseProperties:{ "action": "page.collapseProperties", "pageId": "…", "collapsed": true }collapses the bar with tags and properties for you (falseexpands,nullback to the default). Applies only to you, on all devices; read access to the page is enough.pages.tagadds or removes tags on up to 500 pages at once.- Owners (for a space's tags and properties also that space's owners):
tag.save,tag.delete,tag.merge,tag.reorder,property.save,property.delete,property.reorder. New tags (tag.savewithouttagId) withspaceIdmay also be created by members who may write in that space; withoutspaceId(ornull) a new tag or property applies to the whole workspace (owners only). - Scopes (more): tags and properties in
GET /api/tagscarryspaceId(null: the whole workspace);GET /api/tags/<id>?sameName=1adds same-named tags of other spaces.tag.scope/property.scope({ workspaceId, tagId | propertyId, spaceId, resolve?: "block" | "copy" | "remove", mergeSameName? }, owners only) change the scope,meta.usage({ workspaceId, kind, id, spaceId }) shows the use outside the target first. - Moving:
page.moveCheck(likepage.move) lists the tags and properties of the current space the destination lacks (foreign, withsameNameandcandidates).page.moveandpages.bulktake the choice asmeta:{ "tags": { "<id>": { "action": "map", "to": "<id>" } }, "properties": { "<id>": { "action": "copy" } } }or short"auto"(map to the same-named ones, else copy),"copy","remove". Without a choice,page.moveanswers 409 instead of dropping values. - Templates:
template.savewithoutspaceIdbelongs to the page's space, withspaceId: nullto the whole workspace (owners; a space's tags: 409 orspaceMeta: "remove").template.dependencies({ workspaceId, templateId, spaceId }) lists what a template of another space brings (keystag:<name>,property:<name>);page.create,template.applyandtemplate.copy({ workspaceId, templateId, spaceId, meta? }) take the choice asmeta,template.updatechanges a template's scope withspaceId.GET /api/templatesreturnsspace_idandspace_name. - Text properties (except the title) hold inline Markdown – in
row.create,row.update,rows.bulk,rows.import, when reading and in webhooks:**bold**,_italic_,~~strikethrough~~,`code`,[label](https://…)(http, https, mailto only),\nfor a line break. Send plain text escaped: a backslash before\ * _ ~ ` [ ]. See Formatted text. - Database properties with the tags as options: in
database.updateamultiselectproperty with"tagOptions": true(the server sets itsoptions). Cells hold tag names; a new name inrow.create,row.updateorrows.bulkcreates the tag (members with edit rights, 403 otherwise);rows.importleaves out names that may not become tags. MCP lists such properties inget_databasewithtagOptions: true. - In
GET /api/search,#tag,-#tag,name:value,OR,NOT, parentheses,created_by:,created:,type:,in:andtitle:act as filters (parametertz: the time zone fortodayand similar) (see Search). MCP clients get tags and values withread_pageas frontmatter.
Record versions
row.update carries the version of the record you read. If the record changed since, the server answers 409 – unless you also send base: the values of the changed properties as you read them. Then the change applies as long as nobody else changed those properties meanwhile, and only a real conflict (someone changed the same property) gets the 409. Answers to row.update and the other changes of a record (row.move, row.schedule …) include its new rowVersion; send that with the next change.
Collections via the API
GET /api/collections?workspace=<id>– your collections and those shared with the workspace, in your order:id,name,icon,query,shared,mine,canEdit,ownerName.GET /api/collections/<id>?tz=Europe/Berlin&offset=0&limit=500– runs a collection. It returnscollection,items(pageId,rowId,type,title,icon,spaceId,database,tags,createdAt,updatedAt,createdBy,creatorName; most recently changed first,limitmatches per page, default 500, at most 5000),total,offset,nextOffset(theoffsetof the next page,nullon the last one),truncated(more pages follow) andencryptedLeftOut. Matches always follow the permissions of the token's person.GET /api/collections/preview?workspace=<id>&q=<query>– the first 50 matches of a query that is not saved, and its canonical form (query).POST /api/command:collection.create{ workspaceId, name, query, icon?, shared? }collection.update{ collectionId, name?, query?, icon?, shared? }collection.delete/collection.restore{ collectionId }collection.duplicate{ collectionId, name? }collection.reorder{ workspaceId, order: [ids] }The query is stored in its canonical form and returned asquery.search.saveandsearch.deletestill work and create or delete collections.
- MCP:
list_collections,run_collection(read),save_collection,delete_collection(write).