Skip to content

API Overview

Fullfinity provides a REST API built with FastAPI.

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.

There are two kinds of credential, both presented on the Authorization header:

Use it forExpires
API key (ff_…)Programs — scripts, scheduled jobs, another systemOnly if you set an expiry
Session token (eyJ…)A signed-in person using the appHours; 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.

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/Contact
Authorization: Bearer ff_a1b2c3...
X-DB-NAME: your_database

Keys 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.

POST /auth/authenticate
Content-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"
}

Include the access token in the Authorization header — the same header an API key uses:

POST /api/query/Contact
Authorization: Bearer eyJ...

Session tokens expire within hours and are tied to the database they were issued for. For a program, prefer an API key.

POST /auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJ..."
}
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).

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.

POST /api/create/{model}

Request Body:

{
"name": "New Product",
"price": 99.99,
"category": 5
}

Response:

{
"id": 123,
"name": "New Product",
"display_name": "New Product"
}
PUT /api/update/{model}/{id}

Request Body:

{
"name": "Updated Product",
"price": 149.99
}

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 readonly fields are ignored on create and update alike. A computed field with a setter stays writable.
  • id is 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 ignoredidentifier, 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 identifier and created_date are 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 /api/delete/{model}/{id}

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]

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]
}
KeyMeaning
idsExplicit records. Mutually exclusive with the filter form.
filter_strQ-expression, same syntax as /api/query.
name_searchSearch terms, ANDed, resolved the same way the list resolves them.
exclude_idsRecords 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.

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 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": {...}
}

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:

HeaderMeaning
X-Action-Close1 when the action asked the dialog that triggered it to close
X-Action-Reload1 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.

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"}
]

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
}

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"}
}
POST /api/action-meta/{model}/{method}
{
"context": {"active_ids": [1]}
}

Returns the wizard’s view and its computed defaults.

POST /api/execute/{model}/{method}
{
"request_body": {
"inputs": {"field1": "value1"},
"context": {"active_ids": [1], "active_model": "Contact"}
}
}

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 (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
}
KeyMeaning
fieldsColumn → field path. Relational drill-down uses /: contact/name, order_lines/product, contact/identifier (external ID), tags/ID.
dataRows 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_fieldsBooleans parallel to fields; when true a referenced ManyToOne/ManyToMany target is created if it doesn’t exist.
match_fieldsField 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_formatDisplay format used to parse user-typed date columns (MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, …). ISO and spreadsheet dates parse regardless.
dry_runValidate only — rolls everything back and returns a would-create/update/error summary.
atomicAll-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.

POST /api/clone/{model}
{
"ids": [123]
}

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".

Errors return appropriate HTTP status codes:

CodeDescription
400Bad Request - Invalid parameters
401Unauthorized - Invalid or missing token
403Forbidden - Access denied
404Not Found - Record doesn’t exist
500Internal Server Error

Error response format:

{
"detail": "Error message",
"error": "ErrorType",
"traceback": "..." // Only in dev mode
}

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.