API Overview
Fullfinity provides a REST API built with FastAPI.
Interactive Documentation
Section titled “Interactive Documentation”The stock FastAPI docs endpoints (/docs, /redoc, /openapi.json) are disabled.
Instead, each database serves its own token-gated ReDoc reference at /api-docs. Access
is granted by an ApiDocsShare token (create/manage it in the app), so the API reference
can be shared with a specific consumer without exposing a public Swagger UI.
Authentication
Section titled “Authentication”There are two kinds of credential, both presented on the Authorization header:
| Use it for | Expires | |
|---|---|---|
API key (ff_…) | Programs — scripts, scheduled jobs, another system | Only if you set an expiry |
Session token (eyJ…) | A signed-in person using the app | Hours; refreshable |
For anything automated, use an API key. The login endpoint is built for a human at a browser: it requires a proof-of-work challenge, and it stops to ask for a second factor when the account has one — neither of which a script can sensibly satisfy.
API keys
Section titled “API keys”Create one in Settings → General → API Keys. Each key belongs to its own service account — a login-less account you grant access to exactly as you would a colleague, by giving it groups. The key carries that account’s access and nothing more, so the way to narrow a key is to narrow its account.
The key is shown once, when you create it. It is stored hashed and cannot be retrieved afterwards; if you lose it, create a replacement and revoke the old one. An account can hold several keys at once, which is how you rotate without downtime: add the new key, deploy it, then revoke the old.
POST /api/query/ContactAuthorization: Bearer ff_a1b2c3...X-DB-NAME: your_databaseKeys do not expire unless you give them an expiry date, and there is nothing to refresh. A revoked, expired, or deactivated key is refused immediately.
Signing in as a person
Section titled “Signing in as a person”POST /auth/authenticateContent-Type: application/json
{ "email": "admin@example.com", "password": "admin", "altcha": "<solved challenge>"}altcha is required: fetch a challenge from GET /altcha/challenge, solve it, and send the
result. Without it the request is refused with “Human verification failed.”
If the account has two-factor authentication enabled, no session is issued yet — the response
is {"2fa_required": true, "pending_token": "..."} and you complete the sign-in at
POST /auth/2fa/verify.
The database is selected by host/subdomain or the database selector, not in the request body.
Where neither applies — a plain API client — send the X-DB-NAME header.
Response:
{ "message": "Authenticated", "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "bearer", "expires_at": "2026-01-01 12:00:00 UTC"}Using a session token
Section titled “Using a session token”Include the access token in the Authorization header — the same header an API key uses:
POST /api/query/ContactAuthorization: Bearer eyJ...Session tokens expire within hours and are tied to the database they were issued for. For a program, prefer an API key.
Refresh Token
Section titled “Refresh Token”POST /auth/refreshContent-Type: application/json
{ "refresh_token": "eyJ..."}Core Endpoints
Section titled “Core Endpoints”Query Records
Section titled “Query Records”POST /api/query/{model}Query records with filtering, sorting, and pagination.
Request Body:
{ "fields": ["id", "name", "email"], "filter_str": "Q(active=True)", "order_by": ["name ASC"], "limit": 20, "page": 1, "group_fields": []}Response: by default the endpoint returns a bare array of records:
[ {"id": 1, "name": "John", "email": "john@example.com"}, {"id": 2, "name": "Jane", "email": "jane@example.com"}]When a counter or footer_aggregates is requested, the response is wrapped as
{"count": <n>, "data": [...]} instead (there is no total key).
Fetch Single Record
Section titled “Fetch Single Record”POST /api/fetch/{model}/{id}Fetch a single record with related data.
Request Body:
{ "fields": ["id", "name", "lines"]}Relational data is prefetched automatically from the fields you request (drill into a
relation with a nested field path); there is no separate related parameter.
Create Record
Section titled “Create Record”POST /api/create/{model}Request Body:
{ "name": "New Product", "price": 99.99, "category": 5}Response:
{ "id": 123, "name": "New Product", "display_name": "New Product"}Update Record
Section titled “Update Record”PUT /api/update/{model}/{id}Request Body:
{ "name": "Updated Product", "price": 149.99}Which keys a write body may set
Section titled “Which keys a write body may set”Both write endpoints ignore keys they don’t consider yours to set, rather than failing the request — so you can send a record straight back without filtering it first:
- Computed and
readonlyfields are ignored on create and update alike. A computed field with asetterstays writable. idis ignored on both. It’s an autoincrement column, and on update the id in the URL is the one that counts.- On update only, the record’s identity and audit columns are ignored —
identifier,created_date,created_by,updated_date,updated_by. An edit never re-keys or back-dates the record it’s editing. - On create those same columns ARE yours to set. Importing records with the external key
and original timestamps they already had is what a data migration does, so
identifierandcreated_dateare honoured on the way in. (This is also how a data file declares a record’s cross-reference key.)
The import endpoint follows the same split: rows that match an existing record are applied as updates, new rows as creates.
Delete Record
Section titled “Delete Record”DELETE /api/delete/{model}/{id}Bulk Operations
Section titled “Bulk Operations”Bulk Create — the body is a bare array of record objects:
POST /api/bulk-create/{model}[ {"name": "Product 1", "price": 10}, {"name": "Product 2", "price": 20}]Bulk Update — a bare array of per-record objects, each carrying its own id plus the
fields to change:
PUT /api/bulk-update/{model}[ {"id": 1, "active": false}, {"id": 2, "active": false}]Bulk Delete — a bare array of ids:
DELETE /api/bulk-delete/{model}[1, 2, 3]Selecting by Filter Instead of by Id
Section titled “Selecting by Filter Instead of by Id”Every bulk endpoint also accepts a selection — a query rather than a list of ids. Use it when you want to act on “everything matching this filter” without first fetching the ids, which is how a list acts on a selection that spans more pages (or more groups) than it has loaded:
{ "filter_str": "Q(state='Draft') & Q(company=3)", "name_search": ["acme"], "exclude_ids": [41, 88]}| Key | Meaning |
|---|---|
ids | Explicit records. Mutually exclusive with the filter form. |
filter_str | Q-expression, same syntax as /api/query. |
name_search | Search terms, ANDed, resolved the same way the list resolves them. |
exclude_ids | Records to drop from the match — “all of these except those”. |
exclude_ids is what makes “select everything, then deselect a few” expressible: with
no id list to remove from, the exclusions have to travel with the query.
Per endpoint:
DELETE /api/bulk-delete/{model}{"filter_str": "Q(state='Draft')", "exclude_ids": [41]}
POST /api/clone/{model}{"filter_str": "Q(category=2)"}
PUT /api/bulk-update/{model}{"selection": {"filter_str": "Q(state='Draft')"}, "vals": {"active": false}}Bulk update takes the selection under a selection key alongside one vals object,
because the per-record array form has no place to put a shared set of values. The array
form is unchanged and still applies per-record values.
Resolution is not a bypass. A selection is resolved through the ordinary query path, so ACLs, record rules and company isolation apply exactly as they do to an id list — a filter can never reach a record the caller could not already read.
Execute Methods
Section titled “Execute Methods”Call model methods:
POST /api/execute/{model}/{method}{ "ids": [1, 2, 3], "request_body": { "inputs": {}, "context": {} }}ids may be replaced by a selection (the filter form above), in which case the server
resolves it and runs the method once per matching record. The resolved ids are placed in
context.active_ids, so a method that reads them behaves identically either way.
Export and Print Take Selections Too
Section titled “Export and Print Take Selections Too”Export narrows to a selection with ids (ticked rows) or exclude_ids (everything
the filter matches, minus those). With neither, it exports the whole filtered list —
the behaviour when nothing is selected:
POST /api/export/{model}{"filter_str": "Q(state='Draft')", "exclude_ids": [41], "fields": ["name", "total"]}Print accepts the same selection in place of instance_ids, and renders one
document per matching record:
POST /api/generate_report{"model": "SaleOrder", "report_id": 4, "selection": {"filter_str": "Q(state='Draft')"}}Response:
{ "message": "Method executed successfully", "result": {...}}When the method returns a report
Section titled “When the method returns a report”A method whose result is a report action ({"type": "report", ...}) is answered with the
rendered document itself — a binary body with the report’s content type and a
Content-Disposition filename — not the JSON envelope above. A client calling
/api/execute must therefore branch on the response content type rather than assuming JSON.
Because the document replaces the body, two flags the action may carry alongside it travel as response headers instead:
| Header | Meaning |
|---|---|
X-Action-Close | 1 when the action asked the dialog that triggered it to close |
X-Action-Reload | 1 when the view behind it should refetch (the method also changed the record) |
Both are absent unless the action set them. See Wizards for the
close flag that produces the first.
Name Search
Section titled “Name Search”Search records by display name:
POST /api/name_search/{model}{ "term": "search term", "limit": 10, "filter": "Q(active=True)"}Response: a bare array of serialized records:
[ {"id": 1, "display_name": "Product A"}, {"id": 2, "display_name": "Product B"}]UI Effects
Section titled “UI Effects”Trigger UI effects for field changes:
POST /api/ui-effect/{model}{ "id": 123, "vals": {"contact": 5}, "field_name": "contact"}Response:
{ "currency": 1, "payment_term": 2}Model Registry
Section titled “Model Registry”Get field definitions for a model:
GET /api/get_registry/{model}Response:
{ "name": {"type": "Char", "required": true, "max_length": 255}, "price": {"type": "Monetary", "default": 0.0}, "category": {"type": "ManyToOne", "related_model": "Category"}}Wizard API
Section titled “Wizard API”Get Action Metadata
Section titled “Get Action Metadata”POST /api/action-meta/{model}/{method}{ "context": {"active_ids": [1]}}Returns the wizard’s view and its computed defaults.
Execute Wizard
Section titled “Execute Wizard”POST /api/execute/{model}/{method}{ "request_body": { "inputs": {"field1": "value1"}, "context": {"active_ids": [1], "active_model": "Contact"} }}Export
Section titled “Export”Export records to various formats:
POST /api/export/{model}{ "filter_str": "Q(active=True)", "fields": ["name", "email", "phone"], "import_compatible": false}Records to export are selected by filter_str (and/or name_search), not a list of ids.
The endpoint returns an XLSX file.
Import
Section titled “Import”Import (create or update) records. The client parses the spreadsheet/CSV and
sends a JSON body: a fields array that maps each column (by position) to a
model field path, and data as the raw rows (row 0 is the header).
POST /api/import/{model}Content-Type: application/json
{ "fields": ["name", "email", "categories"], "data": [ ["Name", "Email", "Tags"], ["Acme Corp", "hello@acme.com", "Reseller, VIP"] ], "create_fields": [false, false, true], "match_fields": ["email"], "date_format": "DD/MM/YYYY", "dry_run": false, "atomic": false}| Key | Meaning |
|---|---|
fields | Column → field path. Relational drill-down uses /: contact/name, order_lines/product, contact/identifier (external ID), tags/ID. |
data | Rows including the header row. Blank leading (top-level) cells attach a row to the previous record as a OneToMany line, so multi-line records import from a flat sheet. |
create_fields | Booleans parallel to fields; when true a referenced ManyToOne/ManyToMany target is created if it doesn’t exist. |
match_fields | Field paths used as a natural key to find an existing record to update; unmatched rows are created (upsert). An explicit identifier (external ID) column always takes precedence. |
date_format | Display format used to parse user-typed date columns (MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, …). ISO and spreadsheet dates parse regardless. |
dry_run | Validate only — rolls everything back and returns a would-create/update/error summary. |
atomic | All-or-nothing. Default (false) commits valid rows and reports per-row failures. |
Relational values resolve by external identifier, primary id, or display name. ManyToMany accepts a comma-separated list.
A real import returns a per-row summary:
{ "message": "Import complete", "summary": { "total_records": 2, "created": 1, "updated": 1, "errors": 0 }, "details": { "creates": [...], "updates": [...], "errors": [...] }, "success": true}A dry_run returns the same shape under summary.would_create /
summary.would_update with valid and dry_run: true.
Clone Record
Section titled “Clone Record”POST /api/clone/{model}{ "ids": [123]}Resequence
Section titled “Resequence”Reorder records (used for Kanban column reordering and List row reordering):
POST /api/resequence/{model}{ "dropped_column_id": 3, "after_column_id": 1, "sequence_field": "sequence"}dropped_column_id is the record being moved; after_column_id is the record to place it after (null to move it first); sequence_field defaults to "sequence".
Error Handling
Section titled “Error Handling”Errors return appropriate HTTP status codes:
| Code | Description |
|---|---|
| 400 | Bad Request - Invalid parameters |
| 401 | Unauthorized - Invalid or missing token |
| 403 | Forbidden - Access denied |
| 404 | Not Found - Record doesn’t exist |
| 500 | Internal Server Error |
Error response format:
{ "detail": "Error message", "error": "ErrorType", "traceback": "..." // Only in dev mode}Service Availability
Section titled “Service Availability”The API does not impose request rate limiting (there are no X-RateLimit-*
headers). The one availability response to handle is during a module
install/upgrade: while the database is mid-operation, requests receive a 503
with a Retry-After: 5 header — back off and retry.
Next Steps
Section titled “Next Steps”- Querying Data - Filtering, ordering, and aggregation
- Route Authentication - Auth flow details