REST API
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.
| Method | Path | What it does | Permission |
|---|---|---|---|
| GET | /api | Shows what this key can do: its permissions, its collection limit and its expiry. | Read data |
| GET | /api | Lists the collections this key can read. | Read data |
| POST | /api | Creates a collection for your team. | Edit collections and fields |
| GET | /api | Reads one collection, by id or by name. | Read data |
| PATCH | /api | Renames a collection or changes its description or icon. | Edit collections and fields |
| DELETE | /api | Deletes a team-owned collection or hides a public collection for your team. | Edit collections and fields |
| GET | /api | Lists a collection’s fields: id, name and type. | Read data |
| POST | /api | Adds a field, formula or relation. | Edit collections and fields |
| GET | /api | Reads a page of documents: filters, sorts, a limit and a cursor. | Read data |
| POST | /api | Adds one document, or up to 500 at once. | Edit data |
| PATCH | /api | Changes fields on up to 500 documents in one collection. | Edit data |
| POST | /api | The same read, with the query as one JSON object. | Read data |
| GET | /api | Reads one document by id. | Read data |
| PATCH | /api | Changes fields on one document. | Edit data |
| DELETE | /api | Moves a document to trash. | Edit data |
| PUT | /api | Replaces the documents a relation field points at. | Edit data |
| POST | /api | Brings a document back out of trash. | Edit data |
| POST | /api | Matches rows on one to three fields, updates the matches and adds the rest. Can dry-run first. | Edit data |
| GET | /api | Reads a document’s note as Markdown. | Read data |
| PUT | /api | Replaces a document’s note, refusing if someone saved it since you read it. | Edit data |
| POST | /api | Adds text to the end of a note. | Edit data |
| PATCH | /api | Renames a field or changes its settings or formula. | Edit collections and fields |
| DELETE | /api | Removes a field; primary and system fields stay. | Edit collections and fields |
| GET | /api | Lists the options on a tag field. | Read data |
| POST | /api | Adds a tag option, with an optional colour. | Edit collections and fields |
| POST | /api | Returns a signed token and URL for uploading a file. | Edit data |
| POST | /api | Checks an upload, creates the file and queues parsing. | Edit data |
| GET | /api | Reads file details and a download URL valid for 15 minutes. | Read data |
| DELETE | /api | Moves a team-owned file to trash. | Edit data |
| GET | /api | Reads a document’s comments. | Read data |
| POST | /api | Adds a comment as the key owner. | Edit data |
| PATCH | /api | Edits the key owner’s own comment. | Edit data |
| DELETE | /api | Deletes the key owner’s own comment. | Edit data |
| GET | /api | Reads 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.
Authorization: Bearer YOUR_API_KEYA 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.
| Parameter | Type | Default | What it does |
|---|---|---|---|
| fields | array of field ids or names | every field | Which fields come back. Fields you sort on can come back too. |
| filters | array | none | Which documents come back. Up to 100. |
| sorts | array | newest first | The order they come back in. Up to 100. |
| grouping | object | none | Asks for the answer in groups rather than one flat list. |
| query | string | none | Free text, searched in the fields you ask for and the title. |
| limit | number | 100 | Rows per page. 1 to 500. |
| page | number | 1 | Which page, counting from 1. |
| cursor | string | none | Where the last page ended: meta.next_cursor from it. |
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
}'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.
{
"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.
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.
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.
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.
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.
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.
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.
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.
POST /collections/<collection>/fields
{
"name": "Bid Total",
"type": "formula",
"formula": "{<field id>} * 2",
"settings": { "decimal_places": 2 }
}{<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.
POST /files/uploads
{
"filename": "plans.pdf",
"content_type": "application/pdf",
"size_bytes": 1024
}{
"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.
curl -X PUT '<data.url>' \
-H 'Content-Type: application/pdf' \
-H 'Content-Length: 1024' \
-H 'If-None-Match: *' \
--data-binary @plans.pdfPOST /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.
{
"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.
POST /documents/<document>/comments
{ "body": "<comment text>" }{
"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.
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 type | Write it as |
|---|---|
| text, title | A string |
| number | A number |
| boolean | true or false |
| date | An ISO 8601 date or timestamp |
| tag | One document id |
| tags, relation | An array of document ids |
| people | An array of user ids |
| location | A GeoJSON Point, LineString or Polygon. Lines and polygons are saved as a representative Point; their geometry is not kept. |
| state | The 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.
{
"error": {
"code": "validation",
"message": "Unknown field",
"details": { "field": "<the field you sent>" }
},
"request_id": "<request_id>"
}| Status | Code | When |
|---|---|---|
| 401 | unauthorized | No key, a key that is not valid, or a key whose creator has left the team. |
| 401 | key_expired | The key is past its expiry date. |
| 403 | subscription_expired | The team’s subscription has lapsed. |
| 403 | forbidden_scope | The key lacks the permission, the collection is outside the key’s limit, or the collection is one the API does not open. |
| 403 | forbidden_role | The key has the permission, but its creator’s role no longer allows it. |
| 404 | not_found | No such route, collection or document. A document 404 lists the ids in details. |
| 405 | method_not_allowed | The path exists, but not with that method. The Allow header lists the ones it takes. |
| 409 | conflict | Someone saved the note after you read it. |
| 409 | idempotency_in_progress | The first request with this Idempotency-Key is still running. |
| 409 | idempotency_unrecorded | The write went through but its answer could not be stored. Read before you retry. |
| 422 | validation | The input does not fit: an unknown key, a bad value, an unknown or ambiguous field, a read-only field, a bad cursor. |
| 422 | idempotency_mismatch | The Idempotency-Key was already used for a different request. |
| 429 | rate_limited | Over the minute’s reads or writes. Retry-After says how many seconds to wait. |
| 500 | internal_error | The call failed on Bidlo’s side. |
| 503 | upload_signing_unavailable | File upload signing is not configured on the server. |
| 503 | unavailable | Something 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.
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>" }.
## For agents: how to use Bidlo data - Read and write Bidlo data through its MCP server at https://app.bidlo.ai/api/mcp — streamable HTTP, no OAuth. - Get a Bidlo account first: all Bidlo data needs one, https://www.bidlo.ai/developers/access.md says how to get one, and there is no public tier or self-serve signup. - Authenticate with the header `Authorization: Bearer sk_live_...`; the REST API takes the same key, and `me` shows what the key may do. - A team owner or admin mints that key in the app under Settings, then Features, then API, and chooses Read only, Read and write data, or Full access; collection and field changes need `schema:write`. - Read the Bidlo API docs at https://www.bidlo.ai/developers.md, the OpenAPI 3.1 spec for the REST API at https://www.bidlo.ai/openapi.json, and the reference pages: https://www.bidlo.ai/developers/querying.md (filters, operators, dates, places, pages), https://www.bidlo.ai/developers/mcp.md (the tools), https://www.bidlo.ai/developers/rest.md (the routes) and https://www.bidlo.ai/developers/access.md (keys). - Prefer the MCP tools over REST; use `documents_query` with `within_distance` for anything shaped like "near X". - Call `fields_list` before composing a filter — it returns each field’s id, name and type. - Never invent a Bidlo unit price. Read one off the bid items, or say the forecast is not available.