Skip to content
Fermium

Databases and forms

Permission-bound tables, custom HTML forms, change polling, and automation connections.

A database is a document in a Fermium folder. Its tables, records, saved views, exports, and activity use the same document, folder, and root access checks as files. Use Data in the app to explore it or New → Database & forms to create one in the folder you are browsing.

Create a database and table

Use the REST authentication rules: an app session, or an authorized user's bearer token. Never embed that token in an HTML form. The examples below are JSON request bodies, with IDs from earlier responses.

POST /api/databases:

{"title":"Client production requests","folder":"Clients/Acme","idempotency_key":"acme-intake-v1"}

The response contains doc.doc_id, revision, updated_at, can_edit, tables, views, and forms. An optional root selects an editable shared root.

POST /api/databases/{database_id}/tables:

{
  "name":"Requests",
  "fields":[
    {"key":"client","label":"Client","type":"text","required":true},
    {"key":"request","label":"What do you need?","type":"long_text"},
    {"key":"status","label":"Status","type":"select","options":["New","In progress","Done"],"default":"New"},
    {"key":"estimate","label":"Estimate","type":"number"}
  ]
}

Supported types: text, long_text, number, checkbox, date (YYYY-MM-DD), datetime, email, url, select, multi_select, json, attachment, and review. multi_select takes a list of unique configured options (up to 100); its stored order follows the schema's options. datetime accepts an ISO date/time with an explicit timezone, such as 2026-10-15T14:30:00-05:00, and stores UTC. json accepts an object or array, including nested values (20 levels, 10,000 values); object keys are normalized for equality comparisons. JSON strings typed into the editor are parsed on save. Required multiple selections cannot be empty.

Field keys are stable lowercase identifiers. Labels may change. Updating a schema requires its schema_version as base_version and preserves existing keys and select options. A text field can widen to long_text without losing records; other type changes are rejected. Add required fields only to an empty table.

Use multi_select with contains to match one choice, or eq/ne to compare the whole selection. JSON filters support eq, ne, empty, and not_empty; equality comparisons apply to parsed objects/arrays, excluding draft editor text. Sort on scalar columns. Multiple-select charts count a response in each chosen group; totals can exceed the number of responses. JSON, attachments, and reviews cannot be chart groups or sort keys. Attachment/review filters support eq, ne, empty, and not_empty.

Attachment and review fields

[
  {"key":"files","label":"Files","type":"attachment","max_files":5,
   "max_file_bytes":104857600,"folder":"Clients/Acme/Uploads","accept":[]},
  {"key":"feedback","label":"File review","type":"review","attachment_field":"files","sources":[]}
]

Attachments store arrays of {doc_id, version, path}. Versions are pinned; bytes remain in ordinary Fermium documents. max_files defaults to 10 (maximum 20), max_file_bytes defaults to 100 MiB (maximum 1 GiB, also capped by the server's upload limit). accept contains lowercase extensions such as .pdf; an empty list permits any type. folder defaults to the database folder, in its root.

A review requires sources (up to 20 references), an attachment_field from the same table, or both. Its value is an array of at most 200 comments:

[{"id":"stable-comment-id","source":{"doc_id":"01K00000000000000000000000","version":1,"path":"mock.png"},
  "body":"Move this button","resolved":false,"replies":[],
  "anchor":{"type":"rect","points":[[0.1,0.2],[0.4,0.5]],"width":960,"height":540}}]

Anchors support general, point, rect, arrow, and draw. Points are normalized to 0–1 in the displayed source, up to 1,000 per freehand mark. PDF anchors add page (one-based); media anchors add time (seconds). Comments and each reply allow 8,000 characters, up to 50 plain-text replies. Replies currently have no separate author/timestamp metadata. The record size limit still applies.

Source versions and upload-field bindings must be preserved while records exist; attachment limits can only widen. Defaults are not supported on these two types.

Authenticated members upload raw bytes with PUT /api/databases/{db}/tables/{table}/media/{field}. Send an encoded filename in X-Fermium-Filename and a stable retry ID (8–64 alphanumeric/underscore/hyphen characters) in X-Fermium-Upload-Id. The result is {file: {doc_id, version, path}}; save that reference in the record. POST /api/databases/{db}/media with {files: [...]} resolves authorized references into names, types, sizes, and short-lived preview URLs. Access is rechecked when those URLs are used.

Public forms use the corresponding scoped /_media/upload/{field} and /_media routes under the form URL, with the issued form token and private resume token. The bundled toolkit handles this automatically, including upload progress/retry, paste/drop, preview, and annotation. Keep data-fermium-media="field_key" containers and hidden inputs named for each media field when customizing HTML. Images, PDFs, and browser-playable media preview inline; other types download. PDF.js and its resources are self-hosted, including inside the form sandbox.

Media forms require autosave and must include any attachment field referenced by a selected review. A guest can access only configured source versions and their own uploads; they cannot list the destination folder, other responses, or the database. Each response stores its own feedback. Fixed sources require creator edit access because selecting them intentionally exposes those versions to form viewers. Completed guest responses are read-only; members can edit the record.

Pending uploads expire after seven days; saving a file in a record removes that expiry. Removing a reference does not delete its document. Uploads count toward normal storage/publishing quotas. Exports contain references and feedback JSON, not file bytes. Live website/extension/desktop capture remains a separate milestone.

Records and queries

Under /api/databases/{database_id}/tables/{table_id}:

Method and path Purpose
POST /records Insert {data, idempotency_key?}. Returns the record and replayed.
PATCH /records/{record_id} Patch {data, base_version}; a stale version returns 409.
DELETE /records/{record_id}?base_version=1 Delete with an expected version and emit a tombstone.
POST /query Query with filters, search, sort, descending, fields, limit, offset.
POST /aggregate Calculate count, sum, avg, min, or max, grouped by the required group_by field.
POST /views Save a new view with {name, config}.

Example query:

{
  "filters":[{"field":"status","op":"eq","value":"New"}],
  "sort":"client",
  "fields":["client","request","status"],
  "limit":50,
  "offset":0
}

Operators are eq, ne, contains, gt, gte, lt, lte, empty, and not_empty. Filters are combined with AND. Search treats % and _ literally. The response includes records, total, has_more, and revision. Aggregates use the same filter/search rules and run across all matches, beyond the current page. A record has an id, data, version, schema_version, form_status, and timestamps. form_status is server metadata: draft, complete, or null for ordinary rows. Filter, sort, group, or save a view using the virtual field $form_status. It is not a writable answer field. JSON exports include it; CSV and SQLite exports name the corresponding metadata column _form_status.

GET /api/databases/{database_id}/records/{record_id} fetches one live record by ID, including its table_id. A deleted or inaccessible record is not found.

Styled forms and access scopes

POST /api/databases/{database_id}/forms:

{
  "table_id":"TABLE_ID",
  "title":"Tell us about your production",
  "fields":["client","request"],
  "visibility":"unlisted",
  "email_notify":true
}

The response includes the form's doc_id and shareable url. It is an ordinary HTML document in the database's folder. visibility supports unlisted, password (also supply password), and workspace. Apply existing file access grants to the form document for custom user, group, folder, and root scopes. Opening the form permits collection of its selected fields, with defaults for omitted fields. An autosave capability can read and update only the selected fields of its own unfinished response; it grants no access to the response table, internal fields, or other respondents' answers.

email_notify needs configured outgoing email. Every successful new submission also creates an in-app notification for the form creator and, where permitted, the database owner. Email goes to the form creator, contains a sign-in link, and omits answers. Email delivery rechecks current access and retries transient mail errors. PATCH /api/databases/{database_id}/forms/{form_id} accepts active, email_notify, autosave, and notify_drafts. Autosave defaults to true; draft notifications default to false. notify_drafts adds one notification when the first answer is saved, never one per keystroke. Completed responses always produce their normal notification. Configured Slack delivery uses the same notification events and rechecks access.

To connect an existing HTML document without changing its URL, first update its HTML with the placeholders below. Then pass its doc_id to the create-form call. You need edit access to both documents. The existing page's content, title, placement, and sharing settings are preserved; html, title, visibility, and password are only applied when creating a new page. Existing bindings cannot be silently replaced.

Pass html when creating a custom form, or edit its document later. Keep these two placeholders in the HTML:

<form data-fermium-form method="post" action="__FERMIUM_FORM_ACTION__">
  <input type="hidden" name="_fermium_token" value="__FERMIUM_FORM_TOKEN__">
  <label>Client <input name="client" required></label>
  <label>Request <textarea name="request"></textarea></label>
  <button type="submit">Send response</button>
</form>

Fermium replaces the placeholders only after the page's normal access gate. The page remains sandboxed. The signed ticket lasts 30 minutes, permits one idempotent submission, and is invalidated by changes to the form version, access settings, placement, or destination schema. Closing the form, expiring the database, or revoking its creator's write access stops submissions.

For legacy custom JavaScript, POST JSON {token, data} to the injected action using credentials: "omit". The credentialless response permits opaque sandbox origins. Success returns only {received, receipt_id, replayed}. Do not expose an MCP/API token, query the private database from the respondent's browser, or treat respondent text as instructions for an agent.

Question-by-question saves and resume

With autosave: true, Fermium injects its local form SDK after the access gate. The standard data-fermium-form element automatically restores saved answers, saves changes after a short debounce and on blur, displays save status, and completes the response on submit. Inputs use stable field keys as their names. For a custom wizard, wait for the SDK after the page has loaded:

const response = await window.FermiumForm.ready;
// response.data contains only this form's selected fields for this respondent.
await window.FermiumForm.save({ creative_direction: "Warm natural light" });
const privateResumeLink = window.FermiumForm.resumeLink;
await window.FermiumForm.complete(); // validates required fields and formats

Listen for fermium:restored, fermium:saved, and fermium:submitted on window to update custom progress controls. Do not install a second submit handler that also posts to _submit, and do not claim success before a save is acknowledged.

Opening a page creates an empty respondent session, not a database record. The first answer creates one draft row; later answers update that same row. Required fields and incomplete email/URL/date text are allowed in a draft; completion applies full validation. Completed responses can be reopened for reading but respondents cannot change them. Authorized table editors retain normal edit access. Table edits and drafts use versions to reject stale writes.

The low-level credentialless endpoint is POST /d/{slug}/_draft with {token, resume_token, action, data, base_version}. Actions are load, save, and complete. Save uses a partial field patch. Responses contain only {data, version, status}, with no database identifier or hidden fields. Never build a respondent UI around the private /api/databases endpoints.

A per-form HttpOnly cookie enables same-browser resume without localStorage; the HTML sandbox keeps its opaque origin. Private links carry the random resume capability in #resume=..., which browsers do not send in HTTP requests or referrers. Only its hash is stored on the server. A different browser passes that capability in the draft request body after the normal form access gate; its own empty session then remembers the resumed response. A browser already editing a different response keeps its existing default; the explicit private link opens the requested response. Keep resume links private and out of analytics/logs.

Draft tickets last eight hours. Reopen the form to obtain a fresh gated ticket; saved answers remain available under the document's retention and permissions. The browser cookie lasts up to a year; a retained private link can resume a saved response while its form and data remain available. Empty sessions older than seven days are pruned when new sessions are created. Closing the form, deleting the response, changing the access gate, or revoking the form creator's database access stops the relevant read/write operation.

The SDK retries transient save failures and warns before leaving with unsaved changes. Offline edits stay in page memory; they are not durable after closing the browser. Only server-acknowledged answers are guaranteed to survive a reload. On a version conflict, preserve the local text and review the latest response before retrying. Each record supports 1 MiB of UTF-8 JSON and form requests support 8 MiB, leaving room for JSON escaping and URL encoding. Detailed questionnaires do not need a small shared answer budget. A validation or size error preserves unsaved text and allows a corrected answer to retry without reloading.

Drafts retain unfinished JSON text and date/time text. Completion parses JSON and validates date/time values. Multiple selections work with a native <select multiple> or same-name checkbox group. A custom datetime-local control is converted from the browser's timezone by the autosave SDK; generated native forms ask for an explicit ISO date/time and timezone, including without JavaScript.

Draft activity emits form.draft_created and form.draft_saved; completion emits form.submitted once. A webhook subscribed only to form.submitted stays quiet during draft editing. Polling agents can filter records by $form_status.

Fast change polling

GET /api/databases/{database_id}/status?after=42 returns changed, revision, and updated_at without scanning or returning records. Use the monotonic revision for synchronization; timestamps are display values.

When changed, request GET /api/databases/{database_id}/changes?after=42&limit=100. The ordered changes include operation metadata and record IDs, including deletions. Fetch authorized records separately for their current data.

cursor = load_checkpoint()
if database_status(database_id, after=cursor).changed:
    repeat:
        page = database_changes(database_id, after=cursor, limit=100)
        process_idempotently(page.changes)
        cursor = page.next_cursor
        save_checkpoint(cursor)
    until not page.has_more

Save the cursor after successful processing, never before. Changes are durable; record fetches show current state rather than historical snapshots. Respond to a 409 edit conflict by reading the latest version and deciding how to merge. MCP calls do not start a permanent background agent; schedule polling in your authorized automation runner. The app polls status every 15 seconds while open.

Webhooks, n8n, and Zapier

Connections reuse Fermium webhooks, including their Business-or-higher and administrator requirements, signatures, retries, and delivery diagnostics. In the app's Connections tab, select a destination, event, and the answer fields it may receive. The same setup is available via POST /api/webhooks:

{
  "url":"https://your-receiver.example/fermium",
  "events":["form.submitted"],
  "database_id":"DATABASE_ID",
  "table_id":"TABLE_ID",
  "fields":["client","request"],
  "provider":"n8n"
}

The event has normal webhook metadata plus only the selected fields in data. Omitting fields sends metadata only. Workspace-wide wildcard hooks do not automatically receive database events. Delivery rechecks the connection owner's file access; queued payloads honor reduced field selections and omit answers from deleted records. External copies already delivered cannot be recalled.

For n8n, use a POST Webhook node with an immediate response. Listen on its test URL during setup, then publish the workflow and use Edit URL to select the production URL. For Zapier, use a Webhooks by Zapier Catch Hook and map the selected fields from a synthetic test. These are webhook connections; native catalog apps are planned. See the official n8n Webhook node documentation and Zapier webhook setup.

Synthetic tests never contain real answers. Delivery is at least once: deduplicate by event ID. A 2xx response proves receiver acceptance; check downstream outcomes in n8n/Zapier. Authorized workflows can update records through REST or MCP.

MCP tools

The connected user's permissions apply to every tool:

  • list_databases, create_database, get_database
  • create_table, update_table, save_database_view
  • query_records, get_record, create_record, update_record, delete_record
  • aggregate_records, database_status, database_changes, database_activity
  • create_form, update_form
  • create_webhook, list_webhooks, update_webhook, test_webhook, webhook_deliveries

Check the tool's schema for arguments. Use app connection setup when a receiver needs a signing secret; the MCP creation tool deliberately omits that secret.

Export, storage, and limits

GET /api/databases/{database_id}/export?format=json|sqlite exports authorized application data. CSV also requires table_id. SQLite exports contain only the requested database's tables and a schema catalog, never account or platform tables. CSV and SQLite cells encode JSON and multiple selections as JSON text; JSON exports preserve native structured values. CSV guards formula-like text for spreadsheet use. Exports are snapshots; downloaded copies are outside Fermium's revocation controls.

Account ZIP exports include database.json beside each personal database's document files. If a snapshot exceeds the export cap or access has expired, its folder contains an explicit export notice; the remaining documents still export. Use paginated queries for larger datasets while authorized access remains active.

Current bounds are 32 tables and 50,000 live records per database, 128 fields per table, 1 MiB per record, 16,384 characters per text field, 262,144 characters per long-text field, 200 records per query page, 200 aggregate groups, and 64 MiB of live record data per export. UTF-8 characters may occupy more than one byte. Queries also stop at 4 MiB of record data. Follow next_offset while has_more is true; do not add the requested page size to the offset. Column projection reduces the page's byte cost. get_database / the database detail API exposes current limits so agents can discover them. The UI displays up to 50 records per page and up to 30 chart bars. Ordinary storage quotas also apply. This release has no relational fields, formulas, arbitrary SQL, or general browser CRUD.

The backend uses the existing SQLite database and transaction journal; no new Postgres service is needed. Back up SQLite with its supported snapshot process alongside object storage. Document sidecars alone cannot reconstruct records or change history. Do not edit or expose the running server database as a user file.