REST API
Open in ChatGPTOpen in Claudellms.txtllms-full.txtopenapi.json
Contents
Developer docs
What does the REST API cover?
The REST API is one read-only route over three collections: projects, contractors and facilities. Call it as GET or POST on https://lite.bidlo.ai/api/v1/{collection_id}, where collection_id is one of the three. Nothing in this API creates, edits or deletes anything: those are the only two methods the route answers.
| Collection | What it holds |
|---|---|
| projects | Jobs from advertisement to award: owner, county, letting date, value. |
| contractors | Every company in a market: primes, subs, vendors, suppliers. |
| facilities | Where those companies operate: plants, pits, yards, offices, with coordinates. |
The path also answers to sources, a deprecated alias of facilities kept for callers that already use it. Write facilities in new work. Any other name in that position is a 404, and that check runs before the key is read, so a bad collection answers the same way with or without one.
Everything else your team can see, from bid items to counties to the files on a job, reads through the MCP server instead. See MCP server.
The machine-readable form of this page is /openapi.json, an OpenAPI 3.1 document.
How do I authenticate?
Send your team API key as a bearer token in the Authorization header, and send a team_id with every request. The key decides which team is read, so the team_id you send is never checked against it and one key reads one team.
Authorization: Bearer YOUR_API_KEYA key begins sk_live_. A Bidlo account is required. Anyone can get one by booking a call at https://cal.com/matt-wolfe-yecrho/30min — Bidlo sets the team up, and a team owner or admin then mints the API key in Settings → Features → API. There is no public tier and no self-serve signup. The whole sequence, the key format and what to do when a call comes back 401 or 403 are in Access and keys.
On a GET, team_id is a query parameter. On a POST it is a key in the body. A request without it is a 400 reading team_id is required, key or no key.
How do I make a request?
A GET carries every parameter in the query string and a POST carries them all in a JSON body, and the two take the same parameters. A GET never reads the body and a POST never reads the query string.
The four parameters that are arrays or objects (fields, filters, sorts and grouping) are JSON. On a POST they are JSON already. On a GET each one is JSON text, URL-encoded into the query string. A parameter the route does not know is ignored.
| Parameter | Type | Default | What it does |
|---|---|---|---|
| team_id | string | required | Your team id. Required on every request. A key carries its own team, so the value you send is not checked against it. |
| fields | array of field ids | every field | Which fields come back. Leave it out to take them all. |
| filters | array | none | Which documents come back. |
| sorts | array | newest first | The order they come back in. |
| grouping | object | none | Asks for the answer in groups rather than one flat list. |
| limit | number | 100 | Rows per page. 1 to 2000. |
| page | number | 1 | Which page, counting from 1. |
| query | string | none | Free text, searched across the collection. |
curl -X POST https://lite.bidlo.ai/api/v1/projects \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"team_id": "<team_id>",
"filters": [
{
"field_id": "<bid_date_field_id>",
"operator": "between",
"value": { "start": "2026-10-01", "end": "2026-12-31" }
}
],
"sorts": [{ "field_id": "<bid_date_field_id>", "direction": "asc" }],
"limit": 100
}'The same request as a GET. --data-urlencode does the encoding, so the JSON can be written out plainly.
curl --get https://lite.bidlo.ai/api/v1/projects \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "team_id=<team_id>" \
--data-urlencode 'filters=[{"field_id":"<bid_date_field_id>","operator":"between","value":{"start":"2026-10-01","end":"2026-12-31"}}]' \
--data-urlencode 'sorts=[{"field_id":"<bid_date_field_id>","direction":"asc"}]' \
--data-urlencode "limit=100"One thing to watch on a GET: a JSON parameter that will not parse is dropped rather than refused. A mistyped filters string comes back 200 with the whole collection in it, so read meta and the row count back before trusting a wide answer.
How do I filter, sort and page?
Filters and sorts are arrays whose entries name a field by its field id, and a page is a limit and a page number. The operator a field takes and the shape of its value follow that field’s type, which Querying data sets out type by type.
filters
"filters": [
{
"field_id": "<field_id>",
"operator": "<operator>",
"value": "<shaped by the field type>",
"conjunction": "AND"
}
]field_id and operator are required. value is left out for is_null and is_not_null and is required by every other operator. conjunction is AND unless you write OR.
One field takes one filter. A second filter on the same field is a 400 naming both operators, because the filters are keyed by field id and the second would otherwise replace the first without saying so. Combine them instead: one filter whose value is the whole list of allowed options, or a single between for a date window.
conjunction sits on each filter and there is no nesting. Every filter marked OR forms one group, and that group is ANDed with the filters marked AND.
A filter whose value is empty (null, an empty array, a blank string, half a range) is dropped rather than refused, and the answer comes back without it.
curl -X POST https://lite.bidlo.ai/api/v1/facilities \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"team_id": "<team_id>",
"filters": [
{
"field_id": "<location_field_id>",
"operator": "within_distance",
"value": { "lat": 30.2672, "lng": -97.7431, "radiusInMeters": 50000 }
}
],
"limit": 100
}'curl -X POST https://lite.bidlo.ai/api/v1/contractors \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"team_id": "<team_id>",
"filters": [
{ "field_id": "<name_field_id>", "operator": "contains", "value": "Paving" }
],
"limit": 25
}'sorts
"sorts": [
{ "field_id": "<field_id>", "direction": "asc" }
]direction is asc or desc. Several sorts are applied in the order they appear in the array, and one field takes one sort. Sorting on a location field also needs the point to measure from, as a location key on the sort entry. Without any sorts the newest documents come first.
limit and page
limit is rows per page. It defaults to 100 and runs 1 to 2000; outside those bounds the request is a 400 rather than being trimmed to fit. page counts from 1 and comes back in meta.
No total count comes back and there is no next link. A page shorter than the limit is the last page, and an empty data array is the end.
curl -X POST https://lite.bidlo.ai/api/v1/projects \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"team_id": "<team_id>",
"filters": [
{ "field_id": "<county_field_id>", "operator": "contains", "value": ["<county_document_id>"] }
],
"sorts": [{ "field_id": "<bid_date_field_id>", "direction": "desc" }],
"limit": 100,
"page": 2
}'grouping is the one advanced parameter here. It takes a field id and that field’s type, the data array comes back as groups rather than documents, and page then selects groups rather than rows. Most callers never set it.
What does a response look like?
A response is a data array of documents and a meta object, and every document carries its values under fields, keyed by field id. A document that has never been edited has no updated_at, and the key is left out rather than sent empty.
{
"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>"
}
}
}
],
"meta": { "page": 1, "collection_id": "<collection_id>" }
}field_name is the name your team gives that field, which another team may have changed. field_id is the same for both of them. A field id the collection does not know comes back named Unknown Field with type text rather than throwing.
What sits under value follows the field type.
| Field type | Value | What it is |
|---|---|---|
| date | A string | ISO 8601, or null when the stored value will not parse. |
| text, title, number, material, boolean | As stored | The value the field holds, untouched. |
| tag | { id, value, document_id } | One object: the option, or the one document a single-document relation points at. |
| tags, relation | [ { id, value, document_id } ] | An array of those objects, one per linked document. |
| location | { id, name, address, lat, lng } | The place, with its coordinates. |
| state | { id, name, abbreviation } | The state. |
| people | Ids | This route does not turn them into names. |
| formula | As stored | Whatever the formula returns, in that shape. |
The ids inside a tag, a relation or a people value are ids, not names. Read the document behind one with the MCP tool query_mentioned_document, or go the other way and turn a name into an id with resolve_documents_by_name. Both are on MCP server.
What errors can I get?
Every error comes back as JSON with an error key, and the status says whether the collection, the key, the team or the request was the trouble. A 400 is the request, a 401 or 403 is the key or the team behind it, a 404 is the collection in the path, and a 500 is the read itself.
| Status | When | What comes back |
|---|---|---|
| 400 | team_id was not sent | team_id is required |
| 400 | A POST body that is not valid JSON | Invalid JSON body |
| 400 | A query string that will not parse | Invalid query parameters |
| 400 | limit outside its bounds, or another parameter the config rejects | Invalid view configuration, with a details object naming the parameter |
| 400 | Two filters on one field | Two filters target … Bidlo applies one filter per field, so the second would silently replace the first |
| 400 | A filter on a field id this collection does not have | Unknown filter field … Check the id with get_collection_fields |
| 400 | A filter value in the wrong shape | Filter on … expects …, naming the field, its type and the shape it wanted |
| 401 | No bearer key on the request | Unauthorized |
| 401 | A key that is not a live Bidlo key, or one that has been deleted | Invalid API key |
| 403 | The team subscription has lapsed | Team subscription expired |
| 404 | A collection the path does not know | Invalid collection: … Must be one of: projects, contractors, facilities, sources or a valid collection UUID |
| 500 | The read itself failed | Failed to fetch view content, with the underlying message in details |
A 400 from a filter names the field, its type and the shape it wanted, so read the message back rather than guessing at the value. A 400 on limit carries a details object naming the parameter that failed.
Where do field ids come from?
Field ids come from the MCP tool get_collection_fields, or from the field_id printed beside every value in a response, because the REST API has no field listing of its own today. Read them once for your team, keep them, and carry them into your filters and sorts.
Names are not ids. A team can rename a built-in field, so the same field reads as one name on one account and another name on another, while the id stays put. That is why no id is printed anywhere on this page, and why a filter that names a field in words belongs on the MCP server rather than here.
A field another team added to the same collection is not a field of yours. Filtering on its id is the unknown-field 400, which names get_collection_fields as the thing to call next.
Coming. Two REST routes are being added for this: /api/v1/collections and /api/v1/collections/{collection_id}/fields. Neither is live today. Until they are, read the ids over MCP and carry them across.
Can I call it from a browser?
No, the route sends no CORS headers and answers no preflight, so call it from a server rather than from a page in a browser. A browser will send the request and then refuse to hand your own script the answer.
The key is the second reason. Anything in a page is readable by everyone who loads that page, so a key in front-end code is a key given away. Keep it on your server, let your server call Bidlo, and send your own page only what it needs.
No cache headers come back either. A client that wants to hold an answer decides that for itself.
Where do I go next?
Read Querying data for the operators and value shapes a filter takes, and MCP server for the tool that lists your field ids.
- Querying data: the operators, the value shapes and the date keywords, type by type.
- MCP server: the five tools, including the one that lists your field ids.
- Access and keys: how to get a key, and what a 401 or a 403 means.
- /openapi.json: the same route as an OpenAPI document.
Anything this route will not answer, mail support@bidlo.ai.
## For agents: how to use Bidlo data - Read Bidlo data through its MCP server at https://lite.bidlo.ai/api/mcp — streamable HTTP, no OAuth. - Get a Bidlo account first: all Bidlo data needs one, anyone can get one by booking a call at https://cal.com/matt-wolfe-yecrho/30min, 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. - A team owner or admin mints that key in the app under Settings, then Features, then API. - Read the REST reference at https://www.bidlo.ai/openapi.json, the guide at https://www.bidlo.ai/developers, and its reference pages: https://www.bidlo.ai/developers/querying (filters, operators, dates, places, pages), https://www.bidlo.ai/developers/mcp (the five tools), https://www.bidlo.ai/developers/rest (the route) and https://www.bidlo.ai/developers/access (keys). - Prefer the MCP tools over REST; use `query_database_data` with `within_distance` for anything shaped like "near X". - Call `get_collection_fields` before composing a filter — it returns the operators each field takes. - Never invent a Bidlo unit price. Read one off the bid items, or say the forecast is not available.