REST API

The REST API is one read-only route over three of Bidlo’s collections: projects, contractors and facilities. It takes the same team API key as the MCP server, which needs a Bidlo account, and anyone can get one by booking a call. This page is the whole of it: the request, the parameters, the response and the errors.

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

Contents

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.

CollectionWhat it holds
projectsJobs from advertisement to award: owner, county, letting date, value.
contractorsEvery company in a market: primes, subs, vendors, suppliers.
facilitiesWhere 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.

The header on every request
Authorization: Bearer YOUR_API_KEY

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

ParameterTypeDefaultWhat it does
team_idstringrequiredYour team id. Required on every request. A key carries its own team, so the value you send is not checked against it.
fieldsarray of field idsevery fieldWhich fields come back. Leave it out to take them all.
filtersarraynoneWhich documents come back.
sortsarraynewest firstThe order they come back in.
groupingobjectnoneAsks for the answer in groups rather than one flat list.
limitnumber100Rows per page. 1 to 2000.
pagenumber1Which page, counting from 1.
querystringnoneFree text, searched across the collection.
Jobs letting in a date window, earliest first
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.

The same request as a GET
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

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

Plants, pits and yards within 50 km of a point
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
  }'
Contractors whose name contains a word
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

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

The second page of a county’s jobs, newest first
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.

A response
{
  "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 typeValueWhat it is
dateA stringISO 8601, or null when the stored value will not parse.
text, title, number, material, booleanAs storedThe 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.
peopleIdsThis route does not turn them into names.
formulaAs storedWhatever 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.

StatusWhenWhat comes back
400team_id was not sentteam_id is required
400A POST body that is not valid JSONInvalid JSON body
400A query string that will not parseInvalid query parameters
400limit outside its bounds, or another parameter the config rejectsInvalid view configuration, with a details object naming the parameter
400Two filters on one fieldTwo filters target … Bidlo applies one filter per field, so the second would silently replace the first
400A filter on a field id this collection does not haveUnknown filter field … Check the id with get_collection_fields
400A filter value in the wrong shapeFilter on … expects …, naming the field, its type and the shape it wanted
401No bearer key on the requestUnauthorized
401A key that is not a live Bidlo key, or one that has been deletedInvalid API key
403The team subscription has lapsedTeam subscription expired
404A collection the path does not knowInvalid collection: … Must be one of: projects, contractors, facilities, sources or a valid collection UUID
500The read itself failedFailed 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.