REST API
Base URL
http://localhost:8080/apiIn production, replace it with your instance's domain.
Interactive documentation (Swagger)
The OpenAPI 3.1 documentation is generated automatically:
http://localhost:8080/swagger-ui.htmlThe document behind it is served at /v3/api-docs by the application itself — that is the API's source of truth, and it is what nginx exposes in production.
Static openapi.json file
If you need the document as a file (to import into Postman, to version a contract, to generate a client), ask for the openapi profile:
cd backend
mvn verify -PopenapiThe result lands in backend/core/target/openapi.json.
Why a profile instead of part of mvn verify
The generator reads /v3/api-docs from a running application. The profile starts the application before and stops it after, so it needs a reachable database — the same variables as the local run instructions.
Outside the profile, mvn verify neither writes the file nor tries to start anything, so the build stays green on a machine that only wants to run the tests.
Authentication
JWT (user)
POST /auth/login
Content-Type: application/json
{
"email": "user@company.com",
"password": "yourPassword"
}Response:
{
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"user": { "id": "...", "email": "...", "firstName": "..." },
"roles": ["USER"]
}Send the accessToken on every request:
Authorization: Bearer eyJ...Token refresh
When the accessToken expires (401), use the refreshToken to get a new pair:
POST /auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJ..."
}API Key (external system)
X-API-Key: your-api-keyEvery API key has a configurable per-minute request limit (default 100, 0 = unlimited), set when the key is created or later under Integration → API Keys. Going over the limit answers 429 Too Many Requests:
{ "error": "Too Many Requests", "message": "Rate limit exceeded for this API Key." }Scopes
There are exactly two scopes, defined in ia.flow.model.ApiKeyScope:
| Scope | Authority | Methods it opens |
|---|---|---|
read | SCOPE_read | GET, HEAD, OPTIONS |
write | SCOPE_write | POST, PUT, PATCH, DELETE, and anything else |
The rule is single and lives in one place: ApiKeyScope.requiredFor(httpMethod) decides the scope a request needs, and ApiKeyAuthFilter consults that decision before letting the chain continue. No route names a scope and no controller repeats the list — a new endpoint under /api/a/** is covered the moment it exists.
The two scopes do not imply one another: a write-only key is refused on a GET. A key that needs both sides carries both.
A request without the scope it needs is stopped in the filter — it never reaches the controller — and answers 403 Forbidden:
{
"error": "Forbidden",
"message": "Esta chave de API tem os escopos [read] e o método POST exige o escopo 'write'. Marque esse escopo na chave em Integração → API Keys, ou use uma chave que já o tenha."
}Reading the scopes column fails closed at every ambiguity: an unknown scope is ignored and never promotes the key; an empty list, null or malformed JSON grant nothing, and the key is refused on every method. Before 2026-09-03 an empty list meant "no restriction" — today it means "no reach".
On creation (POST /api/a/api-keys) the scope list is required and must not be empty, and every entry must be a known scope: an invented value is refused with 400 and the message lists the ones that exist. PATCH takes scopes optionally — absent, the key keeps what it has; present, it replaces them.
Migration V0103__api_key_scopes.sql gave ["read","write"] to every key that already existed, including the empty ones and the ones with invented scopes, for the reason recorded in the migration itself (issue #57): what already runs in production keeps the reach it had, and what is created from here on is born minimal.
MCP server (/api/a/mcp)
Flow.IA exposes an MCP (Model Context Protocol) server so an external AI agent — Claude Code, a provisioning script, an IDE assistant — can configure the process in the tenant: the definition itself, forms, knowledge bases, CMS collections, AI agents and the process documentation.
Authentication is HTTP Basic, with a platform user:
{
"mcpServers": {
"flowi": {
"url": "https://<host>/api/a/mcp",
"headers": {
"Authorization": "Basic <base64 of email:password>",
"X-Tenant-ID": "<tenant slug>"
}
}
}
}This shape works in the clients that accept a fixed header: VS Code and Claude Code (CLI). Claude Desktop, claude.ai and ChatGPT add a remote server by URL and negotiate OAuth — they have no field for Basic and none for a header of your own, so they cannot talk to this endpoint directly. The endpoint's 401 answers WWW-Authenticate: Basic realm="flowi-mcp", which is how a client discovers the scheme; OAuth 2.1 is not implemented yet.
Basic rather than JWT because MCP client configuration is static and the token expires in one hour. Failed attempts count against the same limiter as the login endpoint.
X-Tenant-ID says which tenant the client wants; what decides whether it may is the membership check, run at authentication. A slug the user is not a member of does not authenticate — the header names the candidate, it never selects the tenant. With no header the client operates with no tenant, which is the case of a SUPER_ADMIN user.
Use a dedicated user
Create a user just for this (e.g. mcp-bot@...) and grant it ADMIN only on the tenants it should reach — the user's membership in the tenant is what grants access. Do not use a person's account: MCP clients store credentials in plain text, and a leaked personal password means UI login, password change and access to every tenant that person belongs to. A dedicated user can be deactivated without affecting anyone.
Available tools:
| Tool | What it does |
|---|---|
flowi_form_list | Lists the forms of a process |
flowi_form_get | Returns a form with its full schema |
flowi_form_field_types | Lists the accepted field types and how to build the schema |
flowi_form_create | Creates a form (does not publish) |
flowi_form_update | Replaces the schema, creating a new version (does not publish) |
flowi_form_versions | Version history with author — this is the undo path |
flowi_form_publish | Publishes a version, which is what makes it reach users |
flowi_process_list | Lists the tenant's definitions, with the published version |
flowi_process_versions | Version history of one definition |
flowi_process_get_xml | Returns the BPMN/CMMN XML of a version |
flowi_process_validate | Checks a BPMN without deploying: orphans, dead ends, a gateway with no default flow, a missing diagram |
flowi_process_deploy | Deploys a new version of the definition (does not publish) |
flowi_ai_model_list | The AI model catalogue, with the id flowi_agent_create requires |
flowi_agent_list, flowi_agent_create | The process's AI agents |
flowi_agent_attach_to_step | Attaches an agent to a step, with that step's tools |
flowi_agent_suggest_knowledge | Points at the existing bases relevant to a step |
flowi_kb_list, flowi_kb_create, flowi_kb_add_text, flowi_kb_link_process | Knowledge bases (RAG) |
flowi_cms_field_types | The field types and classification levels a collection may carry |
flowi_cms_collection_list, flowi_cms_collection_create, flowi_cms_records_load, flowi_cms_records_query | CMS collections |
flowi_process_doc_get | Reads the process description |
flowi_process_doc_propose | Proposes a new description — returns the text, writes nothing |
flowi_run_startable_list | The processes that can be started, each with its MCP policy |
flowi_run_start | Starts an instance (only if the policy allows it) |
flowi_run_my_tasks | The caller's task inbox, as they see it on screen |
flowi_run_task_get | The task and its form schema, in one answer |
flowi_run_task_claim | Takes a task offered to a role the caller holds |
flowi_run_task_preview | Shows what completing would write and where the instance would go; writes nothing |
flowi_run_task_complete | Completes one task, requiring the preview token |
flowi_run_attach_text | Attaches a text document to an instance |
flowi_run_instance_get | The state of one instance |
flowi_run_why_failed | The AI agent failures of an instance, by business key |
Every tool requires a tenant administrator role (or SUPER_ADMIN). An ordinary user authenticates and reaches no tool at all — the check sits on the class of each tool group, not on a controller, because the MCP path goes through no controller at all.
Each tool declares itself to the client as a read or a write (readOnlyHint, destructiveHint), so a listing does not ask for the same approval a publish does. A write goes through the same bean validation as the equivalent REST route: a slug with a space is refused by the tool exactly as the screen would refuse it.
There are per-call limits: 5000 records on flowi_cms_records_load and 2 million characters on flowi_kb_add_text. Split anything larger.
There is no delete tool and no datasource tool. Deleting has no undo, and a datasource holds an outbound URL and credentials. Publishing is always an explicit call — update_form never publishes on its own, and tasks already open keep the version they locked at creation.
Deploying a version is not publishing it
flowi_process_deploy creates a new version and no tool publishes a definition. While a published version exists, the deployed one starts no instances — it sits there to be reviewed and published by a person. The exception is a definition that has never been published: instance start then falls back to the latest version, and the tool's answer says so.
Before deploying a BPMN a model wrote, call flowi_process_validate: deploying creates a version, and a version cannot be withdrawn. The check also warns when the XML arrived without its diagram section — the process runs, but opens blank in the modeler.
Running the process is closed by default
The flowi_run_* tools start instances and complete tasks. Completing advances the process and no tool undoes it, so each process decides for itself, in the AI assistants (MCP) field of its Configuration tab:
| Value | Start | Complete a task |
|---|---|---|
| Nothing (default) | refused | refused |
| Start and read | allowed | refused |
| Start and complete | allowed | allowed, under the rules below |
The default is closed, and no tool changes it — the person who owns the process does, on screen.
Completing requires three things at once: the process set to Start and complete, the task already claimed by the caller themselves, and a confirmationToken from flowi_run_task_preview with exactly those variables. The token lasts ten minutes and stops matching if any field changes after the preview — that is how the person confirms the final payload rather than an earlier draft of it. No tool claims a task on the way to completing it, and one task is completed per call. The completion records in the task history that it arrived over MCP, so a reading six months later distinguishes what the person typed from what an assistant submitted on their behalf.
The preview reads the diagram and says which activities the task can move to, with each path's condition where one exists. It does not evaluate the conditions — that would mean running the process — so a conditional path is reported as conditional rather than resolved.
To turn the server off, set MCP_SERVER_ENABLED=false. Behind a proxy the endpoint needs proxy_buffering off and a long read timeout: the listening channel is text/event-stream, and the 60s timeout the other API routes carry would sever the session every minute.
The tenant comes from the token, not from a header
The tenant a session operates in is a signed claim inside the access token. There is no tenant header to send: X-Tenant-ID never selects the tenant. Sending it with the same slug the token already carries is accepted and ignored; sending it with a different slug, or with no tenant in the token, answers 400 with a message pointing at /api/auth/select-tenant. The one credential that is not a token — HTTP Basic on the MCP endpoint — names its tenant in that header, and there the membership check runs at authentication: a slug the user does not belong to does not authenticate.
After logging in, choose the tenant:
POST /api/auth/select-tenant
Content-Type: application/json
{ "tenantSlug": "tenant-slug" }The response has the same shape as login, with a new accessToken carrying the tenant and only the roles held in that tenant. Switching tenants means repeating this call and replacing the stored token — there is no in-place switch.
Selecting a tenant the user does not belong to answers 403.
Why the tenant lives in the token
A header is the client's choice, and checking it against the list of active tenants does not stop a user belonging to one tenant from asking for another's data by changing the value. With a signed claim the tenant stops being the client's guess: altering it invalidates the whole token.
Main endpoints
Endpoints follow the prefix convention below. Every path is relative to the /api base.
| Prefix | Scope | Auth |
|---|---|---|
/auth | Authentication / session | No auth on /login, /refresh, /password/forgot, /password/reset and /sso/**; everything else needs a token |
/admin/* | Platform operations | JWT (SUPER_ADMIN only) |
/a/* | Tenant operations | JWT with a selected tenant |
Authentication
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/login | Log in with email/password |
| POST | /auth/refresh | Renew the token |
| POST | /auth/select-tenant | Switch the session's tenant (returns a new token) |
| GET | /auth/me | Current user |
| GET | /auth/me/tenants | The user's tenants |
| GET | /auth/me/session | The current session |
| POST | /auth/password/forgot | Ask for a reset link — always answers 202, registered address or not, and is rate limited per IP |
| POST | /auth/password/reset | Complete the reset with the token from the e-mail |
| POST | /auth/logout | Invalidate the session |
Login response:
{
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"user": { "id": "...", "email": "...", "firstName": "..." },
"roles": ["USER"],
"tenants": [
{ "id": "uuid", "slug": "company-abc", "name": "Company ABC" }
]
}The tenants field lists the tenants the user belongs to. Use the slug in POST /api/auth/select-tenant to obtain that tenant's token.
What POST /auth/logout ends. The two tokens of a single login belong to the same session and carry the same identifier (sid). Logging out deletes the whole session — the access token and the refresh token — so the refresh token of that login stops renewing. Other sessions of the same person, on another device or another browser, stay active: closing one tab is not signing out everywhere.
The access token itself stays valid until it expires (one hour): it is not checked against the database on every request, and that is a cost decision recorded in specs/core/tenant-in-token.md. What logout closes is the window: without that session's refresh token, it is not reopened.
Processes and instances
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/definitions | List the tenant's definitions — the response is the whole list, not paginated |
| GET | /a/definitions/{key} | Detail + startFormKey — from the StartEvent in BPMN, from the casePlanModel's flowable:formKey in CMMN |
| GET | /a/definitions/{key}/variables | The process's configuration variables, not instance ones — requires tenant Admin. GET /a/definitions/{key}/variables/public returns the non-secret ones to any authenticated caller |
| GET | /a/definitions/{key}/monitor | Work parked per step, for one version of the process — ?version= |
| GET | /a/definitions/{key}/monitor/{activityId} | What is parked at that step — ?version= |
| PUT | /a/definitions/{key}/suspend | Suspend the definition — no new instance starts. BPMN only; a case answers 400, because the CMMN engine has no definition suspension |
| PUT | /a/definitions/{key}/activate | Reactivate a suspended definition — BPMN only, for the same reason as suspend |
| POST | /a/instances | Start an instance (BPMN or CMMN) |
| GET | /a/instances | List active instances, paginated — ?page=&size=&type= |
| GET | /a/instances/search | Search instances by process variable, paginated — ?varName=&varValue=&page=&size= |
| GET | /a/instances/{id} | Detail + variables |
| DELETE | /a/instances/{id} | Cancel the instance |
| PATCH | /a/instances/{id}/suspend | Suspend the instance — BPMN only; a case answers 400 |
| PATCH | /a/instances/{id}/activate | Reactivate the instance — BPMN only; a case answers 400 |
| GET | /a/instances/{id}/diagram | Diagram SVG/XML with active activities highlighted |
| GET | /a/instances/failed-jobs | The tenant's failed jobs |
| POST | /a/instances/failed-jobs/{jobId}/retry | Retry a job |
| POST | /a/instances/failed-jobs/{jobId}/deadletter | Move the job to the dead-letter queue, stopping retries |
| GET | /a/ai-task-failures | AI agent steps that failed, paginated (size 20 by default) |
Roles for this table, since the columns do not carry them: starting, listing, reading and drawing an instance only require being authenticated. Cancelling, suspending and reactivating an instance, and the failed-jobs and monitor routes, require tenant Manager or above. Suspending and reactivating the definition requires tenant Admin. Below that the answer is 403.
When an AI agent fails during POST /a/instances, the instance never comes into existence: the process runs synchronously inside the request transaction, so the rollback takes the instance, its tasks and the attachment link with it. GET /a/ai-task-failures is where the attempt is recorded — process, business key, activity, failure type and reason, newest first. Accepts ?businessKey= to filter by document.
For an AI step inside a case the reading changes: the case exists and stays open, only the step failed, and it can be run again. The engine field says which of the two this is — BPMN or CMMN — and on CMMN rows processKey carries the case key.
attemptCount counts the retries. There is one row per step, not one per attempt: when the engine repeats the same step, the existing row is updated and the counter goes up. Nine attempts of one step show as a single row with attemptCount: 9, which is information; nine identical rows would be noise.
Whoever submitted the document receives an AI_TASK_FAILED notification immediately, over the same channel as every other notification — once, on the first failure. Retries do not notify again: repeating the same warning nine times adds nothing and buries the first one. A start triggered by an API key has no user behind it, so the row is written and no notification is sent.
There is no retry and no dead-letter through the API: the record is a fact, not a work item. Reprocessing means submitting the document again.
Per-step monitoring
GET /a/definitions/{key}/monitor returns the diagram of one version and how much work is parked at each of its steps. It requires Manager or above in the tenant.
{
"definitionKey": "fiscal-nfe",
"definitionName": "Tax audit",
"version": 3,
"bpmnXml": "<definitions …>",
"runningInstances": 12,
"runningInstancesOtherVersions": 7,
"versions": [ { "version": 3, "runningInstances": 12 }, { "version": 2, "runningInstances": 7 } ],
"activities": [
{ "activityId": "review", "activityName": "Final review", "activityType": "UserTask",
"count": 11, "byReason": { "USER_TASK": 11 } }
]
}version is optional. Without it the response uses the version with the most running instances (ties go to the higher number) and, if no instance exists, the latest published one. A diagram belongs to a version and instances do not: counting instances from every version onto a single drawing produces a wrong number, so runningInstancesOtherVersions tells you how much was left out rather than hiding it.
A step with nothing parked does not appear in activities — the diagram already draws them all.
byReason classifies what is sitting there:
| Reason | Meaning |
|---|---|
USER_TASK | task waiting on a person |
JOB_FAILED | retries exhausted, in the dead-letter queue |
JOB_RETRYING | failed and still has an attempt left |
TIMER | waiting on a scheduled date |
EXECUTING | execution sitting at the step, with no job and no task |
GET /a/definitions/{key}/monitor/{activityId} lists the items at that step — instanceId, businessKey, startTime, reason and, depending on the reason, taskId, assignee, dueDate or errorMessage. Pass the same version the summary returned, otherwise the detail may describe a different diagram from the one on screen.
Instance history
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/history/instances | Completed instances, paginated — ?tenantId=&type=&page=&size= |
| GET | /a/history/instances/{id} | Completed instance detail |
| GET | /a/history/instances/{id}/activities | The instance's activities |
The listing and the variable search answer with the same page envelope as /a/instances (content, number, size, totalElements, totalPages). size defaults to 20 and is capped at 100 — asking for more returns 100, not an error. Two ever-growing tables sit behind them: ACT_HI_PROCINST and ACT_CMMN_HI_CASE_INST. With no page, a tenant two years in received tens of thousands of rows in a single response.
Tasks
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/tasks | List the user's tasks (?page=&size=&mine=) |
| GET | /a/tasks/completed | List already-completed tasks (history) (?page=&size=) |
| GET | /a/tasks/{id} | Detail + form |
| POST | /a/tasks/{id}/claim | Claim the task |
| POST | /a/tasks/{id}/complete | Complete the task |
| POST | /a/tasks/{id}/unclaim | Return the task to the group |
| POST | /a/tasks/{id}/delegate | Delegate |
| GET | /a/tasks/{id}/variables | Read the task's visible variables |
| POST | /a/tasks/{id}/variables | Write variables without completing the task |
| POST | /a/tasks/{id}/ai-suggest | Fill suggestions for the fields still empty. The body {"draft": {"field": "value"}} carries the form as it stands on screen — without it the server only sees the variables already saved. An absent body or an empty draft is valid. Nothing is written: suggestions go back to the form, and each one carries conflictsWithCurrent (true when the field already holds a different value, and then the client never applies it on its own) |
| GET | /a/tasks/{id}/comments | List comments |
| POST | /a/tasks/{id}/comments | Add a comment |
Task list and detail responses include formVersionId, so you can fetch the exact form version through GET /a/definitions/{processKey}/forms/{key}/versions/by-id/{versionId}.
POST /a/tasks/{id}/complete validates the form on the server
When the task has a formKey and the platform holds that form, its required fields are checked before the task is completed. If one is missing the answer is 400 with Campos obrigatórios não preenchidos: <fields>, and nothing is written — neither the variables nor the process moving on. The check uses the same form version locked when the task was created (the one in formVersionId) and honours conditional rules: a field hidden by a rule is not demanded.
An API client that already sent what the screen sends is unaffected. One that sent less now gets a 400 — fetch the schema by formVersionId before building the body.
Not validated: POST /a/instances (start-form variables), and each field's format, length and range validations, which still run in the browser only.
GET /a/tasks and GET /a/tasks/completed return a page window, not the whole queue. On both, page is zero-based and size defaults to 100, capped at 200 — asking for more returns 200. The response is still a plain array with the same fields; only the number of rows per call is bounded. To go past the first page, ask for ?page=1, and so on.
Both lists also follow the same visibility rule: Manager, Admin and Super Admin see the tasks of the whole tenant, open and completed alike; everyone else sees their own, the ones they are a candidate for, and the ones offered to a group of their process roles.
GET /a/tasks?mine=true narrows the list to the tasks assigned to the caller, ignoring group and process role — including for someone who sees the whole tenant. The default is mine=false, which keeps the full list the caller's visibility allows. There is no mine on /a/tasks/completed.
Forms
A form always belongs to a process, and the path reflects that: the process key comes before the form key. There is no /a/forms/{key} route — calling it returns 404.
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/definitions/{processKey}/forms | List the process's forms |
| POST | /a/definitions/{processKey}/forms | Create a form |
| GET | /a/definitions/{processKey}/forms/{key} | Form detail |
| PUT | /a/definitions/{processKey}/forms/{key} | Save the form (creates a new version) |
| DELETE | /a/definitions/{processKey}/forms/{key} | Delete the form |
| GET | /a/definitions/{processKey}/forms/{key}/versions | List versions (includes createdBy, the UUID of whoever saved it) |
| GET | /a/definitions/{processKey}/forms/{key}/versions/{num} | Version by number |
| GET | /a/definitions/{processKey}/forms/{key}/versions/by-id/{versionId} | Version by UUID |
| POST | /a/definitions/{processKey}/forms/{key}/publish | Publish a version |
Data sources are the exception: they do not belong to a process and sit directly under /a/forms.
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/forms/datasources | List data sources |
| POST | /a/forms/datasources | Create a data source |
| GET | /a/forms/datasources/{key} | Data source detail |
| PUT | /a/forms/datasources/{key} | Update a data source |
| DELETE | /a/forms/datasources/{key} | Delete a data source |
| POST | /a/forms/datasources/{key}/execute | Execute a data source |
Schema validation on POST and PUT
The schema you send has to be valid JSON and, when it carries a fields array, every field must declare a recognised type. An unknown type is rejected with 400, and the message names the culprit and lists the accepted types:
{ "message": "Unknown field type: 'markdown'. Accepted types: text, textarea, ..." }Without this check an invented type would be stored and only break at render time. Anyone building forms through the UI never hits it, because the palette only offers real types; anyone writing the JSON straight against the API does.
Form.io-shaped schemas (with components instead of fields) are accepted without this validation.
Decision Tables
| Method | Endpoint | Description |
|---|---|---|
| GET | /a/decisions | List the tenant's decisions |
| POST | /a/decisions | Deploy a .dmn file |
| POST | /a/decisions/from-template/{key} | Clone from a global template |
| GET | /a/decisions/{key} | Details |
| PUT | /a/decisions/{key} | Update the rules (new version) |
| POST | /a/decisions/{key}/evaluate | Test with inputs |
| DELETE | /a/decisions/{key} | Delete |
Every route above requires tenant Admin or Super Admin, with one exception: POST /a/decisions/{key}/evaluate is open to any authenticated role, because that is what a form calls to test the rule.
CMS
Structured content collections and their records. Reading only requires being authenticated in the tenant, and the answer already comes trimmed by the collection's confidentiality level: anything above your clearance does not appear. Writing requires tenant Manager or above; an API key reaches writing with the write scope.
| Method | Endpoint | Permission | Description |
|---|---|---|---|
| GET | /a/cms/collections | Authenticated | List the collections visible to the caller |
| POST | /a/cms/collections | Manager | Create a collection |
| GET | /a/cms/collections/{id} | Authenticated | Collection details |
| PUT | /a/cms/collections/{id} | Manager | Update a collection |
| DELETE | /a/cms/collections/{id} | Manager | Delete a collection |
| GET | /a/cms/collections/{id}/records | Authenticated | List records, paginated (size 20 by default) |
| POST | /a/cms/collections/{id}/records | Manager | Create one record (201) |
| POST | /a/cms/collections/{id}/records/bulk | Manager | Bulk load |
| PUT | /a/cms/collections/{id}/records/{recordId} | Manager | Update a record |
| DELETE | /a/cms/collections/{id}/records/{recordId} | Manager | Delete a record |
| DELETE | /a/cms/collections/{id}/records | Manager | Empty the collection |
The bulk load takes { "records": [...], "keyField": "code" } and answers { "inserted": n, "updated": n, "total": n }. At most 20,000 records per request — a longer list is refused with 400 — and keyField is capped at 100 characters. With keyField the load is idempotent: a record whose value for that field already exists is updated instead of duplicated. Without keyField everything is inserted, so a clean reload goes through DELETE /a/cms/collections/{id}/records first.
Deleting a record or a collection is permanent: there is no bin and no undo.
Knowledge bases (RAG)
The documents that feed the agent's answer. Every route below requires tenant Manager or above — reading included, because the listing reveals what the AI consults. A caller without that role gets a 403. Creating or changing a base flagged as global stays exclusive to the Super Admin, and that refusal comes from the service itself.
| Method | Endpoint | Permission | Description |
|---|---|---|---|
| GET | /a/knowledge-bases | Manager | List the tenant's bases and the global ones |
| POST | /a/knowledge-bases | Manager | Create a base (201); isGlobal: true requires Super Admin |
| GET | /a/knowledge-bases/{id} | Manager | Base details, with the document count |
| DELETE | /a/knowledge-bases/{id} | Manager | Delete a base — refused while a process is still linked |
| GET | /a/knowledge-bases/{id}/documents | Manager | List documents, paginated (size 20 by default) |
| POST | /a/knowledge-bases/{id}/documents | Manager | Upload PDF, TXT or MD (202 — indexing is asynchronous) |
| DELETE | /a/knowledge-bases/{id}/documents/{docId} | Manager | Delete a document and its vectors |
| GET | /a/knowledge-bases/process/{definitionKey} | Manager | Bases linked to a process |
| POST | /a/knowledge-bases/process/{definitionKey}/link/{id} | Manager | Link a base to the process (201) |
| DELETE | /a/knowledge-bases/process/{definitionKey}/unlink/{id} | Manager | Unlink |
Administration
The whole of /admin/* is SUPER_ADMIN
The rule lives in the security filter, not in each controller: every path under /api/admin/** requires the SUPER_ADMIN platform role. A tenant Admin gets 403 on any row of the table below, the member rows included — there is no block of /admin/* they reach. What a tenant Admin administers lives under /a/*.
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/users | List users, paginated (?search=&page=&size=&sort=) |
| POST | /admin/users | Create a user |
| PUT | /admin/users/{id} | Update a user |
| DELETE | /admin/users/{id} | Deactivate a user |
| PUT | /admin/users/{id}/password | Change a user's password |
| GET | /admin/tenants | List tenants, paginated |
| POST | /admin/tenants | Create a tenant |
| PUT | /admin/tenants/{id} | Update a tenant |
| PATCH | /admin/tenants/{id}/suspend | Suspend a tenant |
| PATCH | /admin/tenants/{id}/reactivate | Reactivate a tenant |
| GET | /admin/tenants/{id}/members | List the tenant's members |
| POST | /admin/tenants/{id}/members | Add a member |
| DELETE | /admin/tenants/{id}/members/{membershipId} | Remove a member |
| PATCH | /admin/tenants/{id}/members/{membershipId}/role | Change a member's role |
| GET | /admin/dmn-templates | List DMN templates |
| POST | /admin/dmn-templates | Create a DMN template |
| PUT | /admin/dmn-templates/{key} | Update a DMN template |
| DELETE | /admin/dmn-templates/{key} | Delete a DMN template |
To read the members of the tenant the session is in without being Super Admin, there is GET /a/members, which requires tenant Admin.
Process definitions (BPMN/CMMN)
There is no /admin/definitions. Deploying, versioning and publishing a definition all live under /a/definitions, tenant-scoped through the token. Every route in this table requires tenant Admin or Super Admin — listing (GET /a/definitions) and reading (GET /a/definitions/{key}) only require being authenticated.
| Method | Endpoint | Description |
|---|---|---|
| POST | /a/definitions/deploy | Deploy a BPMN/CMMN file (multipart) |
| GET | /a/definitions/{key}/versions | Version history |
| POST | /a/definitions/{key}/versions/{definitionId}/publish | Publish a version |
| GET | /a/definitions/{key}/versions/{definitionId}/xml | Export the XML |
| PATCH | /a/definitions/{key}/toggle | Enable/disable the process in the tenant's catalog |
| PUT | /a/definitions/{key}/config | Business Key template and attachment policy |
Deploying is not publishing
POST /a/definitions/deploy creates a new version but does not change which version runs. If the tenant has already published something, instances keep starting that one — the deploy answers 200, the version number goes up, and nothing you just uploaded reaches execution.
To activate it: GET /a/definitions/{key}/versions shows which row has published: true, and POST /a/definitions/{key}/versions/{definitionId}/publish moves the publication. The deploy also logs a WARN when the deployed version is not the published one.
Pagination
List endpoints accept pagination parameters:
GET /admin/users?page=0&size=20&sort=createdAt,descPaginated response:
{
"content": [...],
"totalElements": 45,
"totalPages": 3,
"number": 0,
"size": 20
}number is the index of the page returned, counted from zero — the same value you sent in page. The name comes from Spring Data's page format, and every paginated route in this API uses that same envelope, /a/instances included.
size is capped at 100 on /a/instances; asking for more returns 100 without an error instead of scanning the whole tenant.
Errors
Every error handled by the application follows this shape:
{
"status": 400,
"error": "Bad Request",
"message": "Description of the problem",
"path": "/api/a/instances",
"timestamp": "2026-04-15T10:30:00"
}When the error is a field validation, the same body carries fieldErrors, an array of { "field": "slug", "message": "Slug is required" }.
Two families of response do not go through it, because they are written before the controller:
- the API key filter returns only
errorandmessageon scope403s and rate-limit429s, with nostatus,pathortimestamp; - an
X-Tenant-IDrefusal comes out through the application server's own error handler, in its default format.
Do not treat status or path as guaranteed when reading an error; always read the HTTP status code of the response.
| Status | Meaning |
|---|---|
| 400 | Invalid request data |
| 401 | Not authenticated (token missing or expired) |
| 403 | Not permitted |
| 404 | Resource not found |
| 409 | Conflict (e.g. email already registered; or an AI feature called on an installation with no active model — the message says where to register one) |
| 500 | Internal server error |