REST API

The REST API reads and writes your team’s Bidlo data: any collection your team can see, by id or by name. It takes the same API key as the MCP server. This page is the whole of it: the operations, the request, the response, the writes, the limits and the errors.

Open in ChatGPTOpen in Claudellms.txtllms-full.txtopenapi.json

Contents

What does the REST API cover?

The REST API reads and writes the documents in any collection your team can see, at https://app.bidlo.ai/api/v1. Every path below starts at https://app.bidlo.ai. Reads need a key that can read data; writes need one that can edit data or collections and fields, as the table shows.

MethodPathWhat it doesPermission
GET/api/v1/meShows what this key can do: its permissions, its collection limit and its expiry.Read data
GET/api/v1/collectionsLists the collections this key can read.Read data
POST/api/v1/collectionsCreates a collection for your team.Edit collections and fields
GET/api/v1/collections/{collection_id}Reads one collection, by id or by name.Read data
PATCH/api/v1/collections/{collection_id}Renames a collection or changes its description or icon.Edit collections and fields
DELETE/api/v1/collections/{collection_id}Deletes a team-owned collection or hides a public collection for your team.Edit collections and fields
GET/api/v1/collections/{collection_id}/fieldsLists a collection’s fields: id, name and type.Read data
POST/api/v1/collections/{collection_id}/fieldsAdds a field, formula or relation.Edit collections and fields
GET/api/v1/collections/{collection_id}/documentsReads a page of documents: filters, sorts, a limit and a cursor.Read data
POST/api/v1/collections/{collection_id}/documentsAdds one document, or up to 500 at once.Edit data
PATCH/api/v1/collections/{collection_id}/documentsChanges fields on up to 500 documents in one collection.Edit data
POST/api/v1/collections/{collection_id}/documents/queryThe same read, with the query as one JSON object.Read data
GET/api/v1/documents/{document_id}Reads one document by id.Read data
PATCH/api/v1/documents/{document_id}Changes fields on one document.Edit data
DELETE/api/v1/documents/{document_id}Moves a document to trash.Edit data
PUT/api/v1/documents/{document_id}/relations/{field_id}Replaces the documents a relation field points at.Edit data
POST/api/v1/documents/{document_id}/restoreBrings a document back out of trash.Edit data
POST/api/v1/collections/{collection_id}/documents/upsertMatches rows on one to three fields, updates the matches and adds the rest. Can dry-run first.Edit data
GET/api/v1/documents/{document_id}/noteReads a document’s note as Markdown.Read data
PUT/api/v1/documents/{document_id}/noteReplaces a document’s note, refusing if someone saved it since you read it.Edit data
POST/api/v1/documents/{document_id}/note/appendAdds text to the end of a note.Edit data
PATCH/api/v1/fields/{field_id}Renames a field or changes its settings or formula.Edit collections and fields
DELETE/api/v1/fields/{field_id}Removes a field; primary and system fields stay.Edit collections and fields
GET/api/v1/fields/{field_id}/optionsLists the options on a tag field.Read data
POST/api/v1/fields/{field_id}/optionsAdds a tag option, with an optional colour.Edit collections and fields
POST/api/v1/files/uploadsReturns a signed token and URL for uploading a file.Edit data
POST/api/v1/filesChecks an upload, creates the file and queues parsing.Edit data
GET/api/v1/files/{file_id}Reads file details and a download URL valid for 15 minutes.Read data
DELETE/api/v1/files/{file_id}Moves a team-owned file to trash.Edit data
GET/api/v1/documents/{document_id}/commentsReads a document’s comments.Read data
POST/api/v1/documents/{document_id}/commentsAdds a comment as the key owner.Edit data
PATCH/api/v1/comments/{comment_id}Edits the key owner’s own comment.Edit data
DELETE/api/v1/comments/{comment_id}Deletes the key owner’s own comment.Edit data
GET/api/v1/documents/{document_id}/historyReads a document’s field history.Read data

{collection} and {field} take an id or a name, and {document_id} is always an id; Querying data covers how names match. The app’s system collections (pages, chats, flows, views and the like) are closed to every key, and webhooks and team settings have no route.

/openapi.json is the whole surface as an OpenAPI 3.1 spec; the app serves its own at https://app.bidlo.ai/api/v1/openapi.json, no key needed.

How do I authenticate?

Send your API key as a bearer token in the Authorization header; the key carries your team, so no request sends a team_id. The same key works on the MCP server.

The header on every request
Authorization: Bearer YOUR_API_KEY

A key begins sk_live_. Access and keys covers getting one, what it can do and checking it with GET /me.

How do I make a request?

A GET carries its input in the query string and every other method carries a JSON body, and both refuse a key they do not know. A team_id, or any other key the operation does not take, is a 422.

On a GET, fields, filters, sorts, grouping, page, limit are JSON text, URL-encoded into the query string. One that will not parse is a 422 reading Invalid filters (or whichever it was), not a silent full answer. A long query reads more easily as POST …/documents/query, which takes the same input as a JSON body.

The path wins. A body that repeats a path value with a different one is a 422 reading Conflicting collection_id.

An answer carries an X-Request-Id header once the operation starts. An error before that (an unknown route, a wrong method, JSON that will not parse, a conflicting path value) has its request_id in the body only. Quote it when you write to support.

How do I filter, sort and page?

Filters and sorts name a field by id or name, and you page with a limit of up to 500 and the cursor each full page hands back. Querying data has the operators, the value each field type takes, sorts, paging and grouping.

ParameterTypeDefaultWhat it does
fieldsarray of field ids or namesevery fieldWhich fields come back. Fields you sort on can come back too.
filtersarraynoneWhich documents come back. Up to 100.
sortsarraynewest firstThe order they come back in. Up to 100.
groupingobjectnoneAsks for the answer in groups rather than one flat list.
querystringnoneFree text, searched in the fields you ask for and the title.
limitnumber100Rows per page. 1 to 500.
pagenumber1Which page, counting from 1.
cursorstringnoneWhere the last page ended: meta.next_cursor from it.
Jobs letting in a date window, earliest first (Bid Date is an example field)
curl -X POST https://app.bidlo.ai/api/v1/collections/<collection>/documents/query \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": [
      {
        "field_id": "Bid Date",
        "operator": "between",
        "value": { "start": "2026-10-01", "end": "2026-12-31" }
      }
    ],
    "sorts": [{ "field_id": "Bid Date", "direction": "asc" }],
    "limit": 100
  }'
The same read as a GET (Bid Date is an example field)
curl --get https://app.bidlo.ai/api/v1/collections/<collection>/documents \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode 'filters=[{"field_id":"Bid Date","operator":"between","value":{"start":"2026-10-01","end":"2026-12-31"}}]' \
  --data-urlencode 'sorts=[{"field_id":"Bid Date","direction":"asc"}]' \
  --data-urlencode "limit=100"

What does a response look like?

Every successful operation returns its result under data, with pagination under meta when the operation returns a page. GET /documents/{document_id} returns one document the same shape, under data.

A response (Bid Date is an example field)
{
  "data": [
    {
      "id": "<document_id>",
      "name": "<the value of the collection's primary field>",
      "collection_id": "<collection_id>",
      "created_at": "<iso timestamp>",
      "updated_at": "<iso timestamp>",
      "fields": {
        "<field_id>": {
          "value": "<shaped by field_type>",
          "field_name": "Bid Date",
          "field_type": "date",
          "field_id": "<field_id>",
          "field_value_id": "<field_value_id>"
        }
      }
    }
  ],
  "meta": {
    "page": 1,
    "limit": 100,
    "collection_id": "<collection_id>",
    "next_cursor": "<a string, or null on the last page>"
  }
}

field_name is your team’s name for the field, and field_id does not change when the name does. What sits under value follows the field type; Querying data has each shape.

How do I add and change documents?

With a key that can edit data, POST documents to a collection to add them, PATCH a document to change it, and upsert to match rows against what is already there. Writes change your team’s documents in Bidlo, the same as an edit in the app by the person who made the key.

Create

documents is one { name, fields } object or an array of up to 500. name is the title. Bidlo makes the ids. data holds one full document for a single input, or an array for a batch. Its id is the document id, the same shape as a document read. A field’s default value is not filled in on a document made through the API.

Add a job (Bid Date and County are example fields)
curl -X POST https://app.bidlo.ai/api/v1/collections/<collection>/documents \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: <a new unique string for each write>" \
  -d '{
    "documents": [
      {
        "name": "<title>",
        "fields": {
          "Bid Date": "2026-11-12",
          "County": ["<document_id>"]
        }
      }
    ]
  }'

Update

PATCH /documents/{document_id} takes fields and changes only the fields you send.

Move a bid date (an example field)
curl -X PATCH https://app.bidlo.ai/api/v1/documents/<document_id> \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "fields": { "Bid Date": "2026-11-19" } }'

Bulk update

PATCH /collections/{collection}/documents takes up to 500 { document_id, fields } rows in that collection and answers with data.updated_count.

Change several documents at once
curl -X PATCH https://app.bidlo.ai/api/v1/collections/<collection>/documents \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "documents": [
      { "document_id": "<document_id>", "fields": { "<field_id>": "<new value>" } },
      { "document_id": "<document_id>", "fields": { "<field_id>": "<new value>" } }
    ]
  }'

Upsert

match_fields names one to three fields that together identify a document. Each of up to 500 rows that matches one document updates it, and each that matches none is added. update_mode is overwrite by default, or fill_empty to fill only the fields that are empty. Under data, the answer counts what was created and updated, and an errors array names any row that matched more than one document (ambiguous_match), which is skipped.

Use distinct title, text, number, date, state or tag fields, or forward single-target relations. Matching trims and collapses whitespace, ignores text case and standardizes dates. Tags and relations match by name, not id. An entirely empty match key never matches.

A matching public document with a different name is skipped with public_document_rename, even with fill_empty; update its primary field separately. Send each match key once per batch.

Upsert also accepts labels for tags and relations. It resolves existing labels and creates missing referenced documents on a real run; a dry run creates none.

dry_run: true reports what would happen and writes nothing. Run it first on a large import.

Try an import without writing (Name, Bid Date and Status are example fields)
curl -X POST https://app.bidlo.ai/api/v1/collections/<collection>/documents/upsert \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "match_fields": ["Name", "Bid Date"],
    "rows": [
      { "name": "<title>", "fields": { "Bid Date": "2026-11-12", "Status": "<document_id>" } }
    ],
    "update_mode": "fill_empty",
    "dry_run": true
  }'

Relations

PUT /documents/{document_id}/relations/{field} replaces every document a relation field points at with target_document_ids, up to 500. An empty array clears it. A relation that takes one document refuses more.

Point a relation at two documents
curl -X PUT https://app.bidlo.ai/api/v1/documents/<document_id>/relations/<field_id> \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "target_document_ids": ["<document_id>", "<document_id>"] }'

Trash and restore

DELETE moves a document to trash; nothing is erased. POST …/restore brings back the latest trash entry you can see, or answers 404 reading Document is not in your trash.

Trash a document, then restore it
curl -X DELETE https://app.bidlo.ai/api/v1/documents/<document_id> \
  -H "Authorization: Bearer YOUR_API_KEY"

curl -X POST https://app.bidlo.ai/api/v1/documents/<document_id>/restore \
  -H "Authorization: Bearer YOUR_API_KEY"

Notes

A document’s note reads as Markdown with GET …/note. PUT …/note replaces it and takes base_updated_at: the data.updated_at from your last read, or null for a document with no note. If someone saved the note since, the answer is a 409 conflict; read it again and redo the change. POST …/note/append needs no token from the caller. It checks for concurrent edits up to three times and can still return 409 conflict. It trims the added text and separates it from existing content with two newlines. A note that embeds a view cannot be changed through the API.

Replace a note
curl -X PUT https://app.bidlo.ai/api/v1/documents/<document_id>/note \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "markdown": "<the whole note, as Markdown>",
    "base_updated_at": "<updated_at from your last read, or null>"
  }'

How do I change collections and fields?

Edit collections and fields lets a key create, rename and remove collections and fields, including formulas, relations and tag options. The permission is schema:write. Choose Full access or add this permission under Custom; Read and write data does not include it.

Collections

POST /collections takes a name and an optional description and icon. PATCH /collections/{collection_id} changes those details. DELETE /collections/{collection_id} deletes a team-owned collection or hides a public collection for your team. Its data.status is deleted or soft_deleted. Owned deletion removes its documents and values, dependent pages, and team-owned fields that reference it. A key limited to certain collections cannot create a collection.

Fields and formulas

POST /collections/{collection_id}/fields takes name and type. The types are text, number, tag, tags, relation, date, people, boolean, location, state, formula. PATCH /fields/{field_id} changes a name, settings, formula or relation settings. An existing field’s type cannot change. DELETE /fields/{field_id} removes a field; primary and system fields cannot be removed.

Formula expressions use field ids in braces, never field names. Related values use a relation field id followed by a target field id. Every dependency must be readable by the key.

Add a formula field
POST /collections/<collection>/fields
{
  "name": "Bid Total",
  "type": "formula",
  "formula": "{<field id>} * 2",
  "settings": { "decimal_places": 2 }
}
A formula through a relation
{<relation field id>}.{<target field id>}

A relation needs reference_collection_id. Its optional relation object takes enable_two_way and reverse_field_name. Linked fields must belong to your team to change the link. Targets and linked fields must be within the key’s access.

settings accepts only the settings listed in the OpenAPI request. Raw field metadata and server-managed result types are refused. Invalid formulas or settings return 422 validation.

Tag options

GET /fields/{field_id}/options lists options with Read data. POST /fields/{field_id}/options adds one with Edit collections and fields. Send name and, optionally, color: a valid palette name or hex colour, only on a colour option collection. Otherwise the colour is a 422. Rename or delete an option through the document routes, using its document id.

A tag field can create its own hidden option collection. A key limited to certain collections must supply an existing allowed option collection as reference_collection_id.

How do I upload a file?

Ask the API for upload instructions, PUT the bytes directly to the signed storage URL, then finalize the upload through the API. Upload and finalize need Edit data. The file ceiling is 250 MiB (262,144,000 bytes); API request bodies are capped at 4.5 MB, so the bytes go directly to storage.

Request upload instructions
POST /files/uploads
{
  "filename": "plans.pdf",
  "content_type": "application/pdf",
  "size_bytes": 1024
}
Upload instructions under data
{
  "data": {
    "upload_id": "<signed upload token>",
    "url": "<presigned S3 URL>",
    "method": "PUT",
    "headers": {
      "Content-Type": "application/pdf",
      "Content-Length": "1024",
      "If-None-Match": "*"
    },
    "expires_at": "<ISO timestamp>"
  }
}

The example declares a 1024-byte PDF; send the actual size and content type of your file. Upload instructions expire after 15 minutes. Copy every returned header to the PUT, including Content-Length and If-None-Match. Send only the file bytes to the returned URL.

PUT the file bytes to storage
curl -X PUT '<data.url>' \
  -H 'Content-Type: application/pdf' \
  -H 'Content-Length: 1024' \
  -H 'If-None-Match: *' \
  --data-binary @plans.pdf
Finalize with the same key before the token expires
POST /files
{
  "upload_id": "<data.upload_id>",
  "attach_to": { "document_id": "<document>", "field_id": "<field>" }
}

Finalize checks the uploaded size and content type, creates the file and queues parsing for supported file types. Reusing a valid upload_id returns the same live file. attach_to is optional and replaces the target field’s value; it does not append. The field must be writable and point at Files. A limited key must allow Files and the attachment’s collection.

File details from finalize or GET
{
  "data": {
    "id": "<file>",
    "name": "plans.pdf",
    "filename": "plans.pdf",
    "content_type": "application/pdf",
    "size_bytes": 1024,
    "download_url": "<signed download URL>",
    "download_expires_at": "<ISO timestamp>"
  }
}

GET /files/{file_id} needs Read data and returns a download URL valid for 15 minutes. Only live team-owned files in the team’s storage can be downloaded this way. DELETE /files/{file_id} needs Edit data and moves the file to trash. Generic document writes to Files remain blocked.

503 upload_signing_unavailable means upload signing is not configured on the server. An invalid or expired upload token, or bytes that do not match the declared size or type, returns 422 validation.

How do I read comments and history?

Read a document’s comments or field history with its document id, a limit and the cursor from the previous page. Both reads need Read data. Their limit defaults to 100 and runs from 1 to 500. Results are under data; pass meta.next_cursor back as cursor until it is null.

Comments

GET /documents/{document_id}/comments lists comments. POST /documents/{document_id}/comments creates one. Send the comment as a string under body. PATCH /comments/{comment_id} edits that body; DELETE /comments/{comment_id} deletes the comment. Writes need Edit data. The author is the key owner, and a key can edit or delete only that person’s own comments.

Add a comment
POST /documents/<document>/comments
{ "body": "<comment text>" }
A page of comments
{
  "data": [{
    "id": "<comment>",
    "user_id": "<user>",
    "author": null,
    "body": "<comment text>",
    "parent_id": null,
    "created_at": "<ISO timestamp>",
    "updated_at": null
  }],
  "meta": { "next_cursor": null }
}

Field history

GET /documents/{document_id}/history returns field changes the key can read. old_value may be null when the app did not record it. Fields and linked values outside the key’s access are left out. A page can be empty and still have a next cursor; keep paging until the cursor is null.

Read field history
GET /documents/<document>/history?limit=100&cursor=<next cursor>

Items are newest first by created_at, then id. Each has id, created_at, action_type, field_id, new_value, old_value, source_url, user, field and meta.

field has id, name and type. user is null or has id, first_name, last_name and avatar; the last three can be null. source_url can be null; meta is always null.

What value does each field take?

Write each value in its plain form: text as a string, a number as a number, a date in ISO 8601, and a tag, tags or relation as document ids. On a create or an upsert, the title goes in name, never in fields. On an update, the title is a field like any other: send the primary field’s id or name inside fields.

Field typeWrite it as
text, titleA string
numberA number
booleantrue or false
dateAn ISO 8601 date or timestamp
tagOne document id
tags, relationAn array of document ids
peopleAn array of user ids
locationA GeoJSON Point, LineString or Polygon. Lines and polygons are saved as a representative Point; their geometry is not kept.
stateThe state’s name or code

Omitting a field leaves it unchanged. Send null or an empty string to clear a scalar field; send an empty selection to clear tags or people, and [] to clear a relation.

Formula, rollup and lookup fields are read-only, and so is a field an integration added and keeps read-only. An existing field an integration was connected to stays writable. Writing a read-only field is a 422 naming the field. So is sending the same field twice in one row. A document id that does not exist, is in trash or sits in a collection your team cannot see is a 404 listing those ids in details. One in a collection the key is limited away from, or one the API does not open, is a 403.

Is a bulk write all or nothing?

No: every row is checked before anything is written, but the write is not one transaction, so a failure partway can leave earlier rows written. Most mistakes in a batch are caught by that first check, and nothing is written.

When a batch does fail partway, read before writing again: query the documents you sent, or re-run the batch as an upsert, which updates what is already there instead of adding it twice. An Idempotency-Key does not cover this: it replays an answer that was recorded, and a request that failed partway recorded none, so a plain retry writes again.

What errors can I get?

Every error is JSON with an error object holding a code and a message, and a request_id to quote to support. Some errors add details: the field, the ids or the parameter that failed.

An error
{
  "error": {
    "code": "validation",
    "message": "Unknown field",
    "details": { "field": "<the field you sent>" }
  },
  "request_id": "<request_id>"
}
StatusCodeWhen
401unauthorizedNo key, a key that is not valid, or a key whose creator has left the team.
401key_expiredThe key is past its expiry date.
403subscription_expiredThe team’s subscription has lapsed.
403forbidden_scopeThe key lacks the permission, the collection is outside the key’s limit, or the collection is one the API does not open.
403forbidden_roleThe key has the permission, but its creator’s role no longer allows it.
404not_foundNo such route, collection or document. A document 404 lists the ids in details.
405method_not_allowedThe path exists, but not with that method. The Allow header lists the ones it takes.
409conflictSomeone saved the note after you read it.
409idempotency_in_progressThe first request with this Idempotency-Key is still running.
409idempotency_unrecordedThe write went through but its answer could not be stored. Read before you retry.
422validationThe input does not fit: an unknown key, a bad value, an unknown or ambiguous field, a read-only field, a bad cursor.
422idempotency_mismatchThe Idempotency-Key was already used for a different request.
429rate_limitedOver the minute’s reads or writes. Retry-After says how many seconds to wait.
500internal_errorThe call failed on Bidlo’s side.
503upload_signing_unavailableFile upload signing is not configured on the server.
503unavailableSomething the call depends on could not be reached. Try again shortly.

When the answer carries an X-Request-Id header, it matches request_id. Branch on code, not on message: the message is written for people and may be reworded.

How many calls can I make?

Each key gets 600 reads and 120 writes per clock minute, and a call over that is a 429 with a Retry-After header. Two keys do not share a count.

A counted answer carries X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit, wait the Retry-After seconds, which run to the start of the next minute. MCP server covers the limit on tool calls.

How do I retry a write safely?

Send an Idempotency-Key header with the write, and a retry with the same key and the same request gets the first answer back for 24 hours, once that answer was recorded. It works on any method but GET.

The header
Idempotency-Key: <a new unique string for each write>

The key is 1 to 200 characters, new for every write. The same key with a different request is a 422 idempotency_mismatch, and a retry while the first is still running is a 409 idempotency_in_progress. A request that failed is not recorded, so a retry runs it again; one that never finished is freed after ten minutes.

What about the older collection routes?

The older routes for projects, contractors and facilities still answer on their old contract, but new code should use the collection documents routes. They are /api/v1/projects, /api/v1/contractors and /api/v1/facilities, and sources is a deprecated alias of facilities. Each request must send a team_id, limit runs up to 2000 with page but no cursor, and most errors come back as { "error": "<message>" }.