Data API
Filtering
Filter grammar for the Parcel Data API search endpoint. OR within a field, AND across fields.
Pass a filters object in the body of any POST /data/{dataset}/search request.
Each key is a filterable field for that dataset. Three value shapes are supported.
Field filter
{ "filters": { "<field>": { "value": "<scalar> | [<scalar>, ...]", "exact_match": false, "exclude": false } }}| Property | Type | Required | Default | Notes |
|---|---|---|---|---|
value | scalar or array | yes | An array of values means OR (match any). | |
exact_match | boolean | no | false | Text fields only. Exact case-insensitive match instead of fuzzy. Not accepted on enum-backed fields (a closed vocabulary is always exact); passing it there is a 400. |
exclude | boolean | no | false | Negates the filter (exclude records that match). |
Example: projects in one of two stages
{ "filters": { "stage": { "value": ["approved", "permitted"] } }}Range filter
{ "filters": { "<field>": { "min": "<number | YYYY-MM-DD>", "max": "<number | YYYY-MM-DD>" } }}Both min and max are optional and inclusive. Omit either to make the range open-ended. Numeric fields accept integers or floats. Date fields use YYYY-MM-DD (day-granular, UTC).
Example: projects with 50 to 200 residential units
{ "filters": { "residential_units": { "min": 50, "max": 200 } }}Example: signals filed in 2025
{ "filters": { "filed_at": { "min": "2025-01-01", "max": "2025-12-31" } }}Relational filter
A relational filter matches records that have at least one related record satisfying the inner criteria. The outer key is the relational key for the dataset (e.g. has_account on projects); the value is itself a filters object using the related dataset’s filterable fields.
{ "filters": { "<relational_key>": { "<related_field>": { "value": "..." } } }}Example: projects that have a developer account named “Suffolk”
{ "filters": { "has_account": { "role": { "value": "developer" }, "name": { "value": "Suffolk", "exact_match": false } } }}Excluding related records (without_*)
To match records that have NO related record satisfying criteria, use the
without_* relational keys. On projects: without_account, without_signal,
without_contact. On accounts: without_project, without_signal,
without_contact. Each takes the same inner filters as its has_* twin and
compiles to NOT EXISTS.
This is different from a field-level exclude inside a has_* block:
has_account: { role: { value: "engineer", exclude: true } } matches projects
that have an account whose role is NOT engineer (they have some non-engineer
account), whereas without_account: { role: { value: "engineer" } } matches
projects that have NO engineer account at all.
Example: projects with no engineer account
{ "filters": { "without_account": { "role": { "value": "engineer" } } }}Combining filters
Conditions across different top-level keys (including relational blocks) are combined with AND. Values within a single field filter are combined with OR.
result = (field_A matches any of its values) AND (field_B matches any of its values) AND (relational_key_C: related record matches inner criteria)Combined example: approved Boston projects with 50+ units that have a GC on the team
curl -X POST https://api.parcelengineering.com/api/v1/data/projects/search \ -H "Authorization: Bearer pcl_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "filters": { "stage": { "value": "approved" }, "city": { "value": "Boston" }, "residential_units": { "min": 50 }, "has_account": { "role": { "value": "gc" } } }, "sort": { "field": "last_signal_at", "order": "desc" }, "limit": 25 }'const res = await fetch( 'https://api.parcelengineering.com/api/v1/data/projects/search', { method: 'POST', headers: { Authorization: 'Bearer pcl_your_api_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ filters: { stage: { value: 'approved' }, city: { value: 'Boston' }, residential_units: { min: 50 }, has_account: { role: { value: 'gc' }, }, }, sort: { field: 'last_signal_at', order: 'desc' }, limit: 25, }), });const { data, metadata } = await res.json();import httpx
resp = httpx.post( "https://api.parcelengineering.com/api/v1/data/projects/search", headers={"Authorization": "Bearer pcl_your_api_key"}, json={ "filters": { "stage": {"value": "approved"}, "city": {"value": "Boston"}, "residential_units": {"min": 50}, "has_account": { "role": {"value": "gc"}, }, }, "sort": {"field": "last_signal_at", "order": "desc"}, "limit": 25, },)data = resp.json()["data"]Unknown keys and invalid enum values
Passing a key that is not a valid filter field for the dataset returns a 400 error. Passing a value outside an enum field’s allowed set returns the same 400 validation shape; the allowed values appear in a nested invalid_enum_value issue. See Errors for both response shapes.
Per-dataset filterable fields
Each dataset page lists its filter keys, range keys, and relational keys.
| Dataset | Filter keys | Range keys | Relational keys |
|---|---|---|---|
| accounts | account_type, city, state, name | none | has_project, without_project, has_signal, without_signal, has_contact, without_contact |
| contacts | name, title, email, account_id | none | at_account, on_project |
| projects | stage, primary_use, city, state, name | residential_units, gross_floor_area_sf, cost_of_construction_usd, last_signal_at | has_account, without_account, has_signal, without_signal, has_contact, without_contact |
| signals | signal_type, project_id | filed_at, discovered_at | for_project, for_account |
Allowed enum values for fields like stage, signal_type, primary_use, account_type, and the role relational fields are listed on the Enums page. A value outside the allowed set returns a 400, not zero rows. The OpenAPI schema advertises each enum field’s allowed values inline. The MCP search tool schemas advertise structure only; the vocabularies are served on demand by the search_fields and get_fields tools. See Discovering fields below.
Discovering fields
Filter vocabularies (which fields exist, which enum values are valid, what relational sub-fields accept) are served by the fields dataset rather than duplicated in documentation or tool schemas. Call POST /data/fields/search with a dataset to get the full vocabulary in one response, or fetch a single descriptor with GET /data/fields/{dataset}/{key}. Over MCP, the same discovery flow is search_fields({dataset: "projects"}) first, then compose the search; invalid enum values are rejected by the API with a 400 naming the allowed values.