Developer API
Querying the API
Every queryable resource accepts the same parameters. Learn them once and they work everywhere: pick your columns, filter with a consistent operator grammar, sort on indexed columns, walk an entire dataset with cursors, and pull only what changed since your last sync.
Overview
A queryable endpoint takes seven reserved parameters. Everything else in the query string is read as a filter on a field of that resource. An unrecognised parameter is rejected with a 400 rather than ignored, so a typo cannot quietly return you the unfiltered dataset and bill it to your quota.
| Parameter | Default | What it does |
|---|---|---|
| select | resource default | Comma separated list of fields to return. |
| sort | resource default | Comma separated sort keys. Prefix with - for descending. |
| limit | 50 | Page size, capped by your plan. |
| offset | 0 | Offset paging. Mutually exclusive with cursor. |
| cursor | none | Opaque keyset cursor from a previous response. |
| count | exact, or estimated on very large datasets | exact, estimated, or none. |
| updated_since | none | Only rows modified at or after this ISO timestamp. |
curl -H "Authorization: Bearer $CE_API_KEY" \
"https://commonelements.com/api/v1/associations/lookup?\
select=id,name,state,unit_count&\
state=FL&unit_count[gte]=100&\
sort=-unit_count&limit=50"Field selection
select narrows the response to the fields you name, in the order you name them. On a large pull this is the single cheapest optimisation available: it cuts transfer size, parse time, and the memory your job needs.
Field names are validated against the resource. An unknown name returns a 400 that lists every available field, so you can discover a resource's shape from an error rather than from guesswork. select=* is deliberately not supported: naming your fields keeps your integration stable when we add columns.
A small number of fields carry individual contact data and are never in the default projection. They are available on paid plans only, and only when you request them explicitly. Asking for one on a free key returns field_not_entitled naming the field.
Filtering
Filters are written field[operator]=value. The bare form, field=value, means equals. Multiple filters combine with AND.
Values are validated against the field's type before the query runs, so a bad value returns a clear 400 rather than a database error. In patterns, * is the only wildcard: your % and _ are matched literally.
# Florida condos over 100 units, recorded in the first half of 2026,
# excluding two counties, with a website on file.
?state=FL
&subtype=condo
&unit_count[gte]=100
&recorded_on[between]=2026-01-01..2026-06-30
&county[nin]=Broward,Miami-Dade
&website[is]=notnullOperator reference
| Operator | Example | Meaning |
|---|---|---|
| eq | ?county=Broward | Equal. The bare form of any field is an eq filter. |
| neq | ?county[neq]=Broward | Not equal. Follows SQL: rows where the column is null are also excluded. |
| gt / gte | ?unit_count[gte]=100 | Greater than, greater than or equal. Numerics and dates. |
| lt / lte | ?unit_count[lt]=500 | Less than, less than or equal. |
| between | ?recorded_on[between]=2026-01-01..2026-06-30 | Inclusive range, written low..high. |
| in | ?status[in]=active,expired | Any of. Comma separated, with \, for a literal comma. Up to 200 values. |
| nin | ?status[nin]=void | None of. Same list syntax as in. |
| like | ?name[like]=*Oak Ridge* | Case-sensitive pattern. * is the wildcard. |
| ilike | ?name[ilike]=*oak ridge* | Case-insensitive pattern. |
| contains | ?service_states[contains]=FL,GA | Array column contains all of these values. |
| is | ?website[is]=null | Null test. Accepts null or notnull. |
Sorting
sort=-recorded_on,parcel_id sorts by recorded_on descending, then parcel_id ascending. The field:desc spelling works too. Up to four keys.
Sorting is restricted to indexed columns. Ask for one that is not indexed and you get an explicit sort_not_indexed error listing the columns that will work, instead of a request that scans a multi-million row table and times out with no explanation. If you need an ordering we do not index, tell us which one and we will look at adding it.
Nulls always sort last, in both directions, so a page boundary means the same thing on every request.
Pagination
Two modes. Offset paging (limit and offset) is right for jumping to a known page of a small result set. Cursor paging is right for everything else, and it is the only way to read a large dataset end to end.
Offset paging has a hard ceiling. Past it we return offset_too_large rather than accepting the request, because a deep offset makes the database produce and discard every skipped row: the cost grows with depth until it times out. That is a real limit of offset paging, not a plan restriction, and cursors remove it entirely.
Every response carries a page envelope:
{
"ok": true,
"data": [ ... ],
"page": {
"limit": 500,
"returned": 500,
"has_more": true,
"next_cursor": "eyJ2IjoxLCJyIjoiYXNzb2..."
},
"total": { "value": 15512338, "exact": false, "source": "planner_estimate" },
"meta": { "resource": "association-parcels", "select": ["id","parcel_id"], "plan": "growth" }
}has_more is a fact, not an estimate: we read one row beyond your page to determine it. You never need a trailing empty request to discover you finished.
Walking a whole dataset
Take page.next_cursor from a response and pass it back as cursor. Repeat until has_more is false. Each page costs the same whether it is the first or the fifteen thousandth.
import requests
BASE = "https://commonelements.com/api/v1/associations/lookup"
headers = {"Authorization": f"Bearer {API_KEY}"}
params = {"select": "id,name,state", "state": "FL", "limit": 500, "count": "none"}
while True:
r = requests.get(BASE, headers=headers, params=params).json()
for row in r["data"]:
handle(row)
cursor = r["page"].get("next_cursor")
if not cursor:
break
params["cursor"] = cursorThree rules for cursors:
- A cursor is opaque. Pass it back byte for byte. It is signed, so an edited cursor is rejected rather than silently returning the wrong slice.
- Do not change your filters or sort mid-walk. A cursor is only meaningful under the query it was issued for, so we return
cursor_mismatchinstead of a wrong answer. Change the query, start the walk again. cursorandoffsetcannot be combined, and a cursor walk runs in the resource's cursor order. If you need a different ordering, use offset paging.
Cursors are available on every plan, including free. Your monthly request quota is the meter, not your ability to paginate.
Counts
total is an object, not a bare number, because you need to know how much to trust it:
count=exactruns a real count.exactis true. Accurate, and on a multi-million row dataset it is usually the most expensive part of the request.count=estimatedreturns the query planner's estimate.exactis false andsourcesaysplanner_estimate. Free, and on a filtered query it can be off by a wide margin. Fine for a progress bar, not for reconciliation.count=noneskips counting.valueis null, never zero, so "we did not count" is never confused with "nothing matched". Use this for cursor walks: the total does not change per page.
We never return an estimate labelled as exact. If a plan limit causes an exact count to be downgraded, total.note says so on that response.
Syncing changes
updated_since returns only rows modified at or after an ISO timestamp. A bare date is read as midnight UTC. Combined with a cursor walk, this is the whole daily-delta pattern:
# Nightly: everything that moved since yesterday's run.
GET /api/v1/associations/lookup
?updated_since=2026-08-15T00:00:00Z
&sort=updated_at
&select=id,name,state,updated_at
&limit=500
&count=none
# then follow page.next_cursor until has_more is falseRecord the largest updated_atyou received and use it as the next run's updated_since.
How far back updated_since can reach depends on your plan. If your request reaches further back than your plan allows we clamp it forward and say so in meta.notes on that response. We never truncate a delta silently, because a sync that quietly misses rows is worse than one that fails.
Conditional requests
Every query response carries an ETag. Send it back as If-None-Match and, if nothing changed, you get a 304 with no body.
curl -sD - -o /dev/null \
-H "Authorization: Bearer $CE_API_KEY" \
"https://commonelements.com/api/v1/associations/lookup?state=FL&limit=100"
# ETag: "n5Qk1s0Xv3rYb2..."
curl -H "Authorization: Bearer $CE_API_KEY" \
-H 'If-None-Match: "n5Qk1s0Xv3rYb2..."' \
"https://commonelements.com/api/v1/associations/lookup?state=FL&limit=100"
# HTTP/1.1 304 Not ModifiedThe ETag covers the rows, the total and your pagination position. It does not change when only plan copy or rate-limit context changes, so it stays useful.
One thing to be clear about: a 304 still counts as one request against your monthly quota. We authenticate and run the query before we can tell whether anything changed. What a 304 saves is transfer and parse time, which on a large page is the expensive part.
Rate-limit headers
Every response tells you where you stand.
| Header | Example | Meaning |
|---|---|---|
| X-RateLimit-Limit | 10000 | Requests included in your plan this month. |
| X-RateLimit-Used | 4211 | Requests consumed this month, including this one. |
| X-RateLimit-Remaining | 5789 | Requests left before you are throttled. |
| X-RateLimit-Reset | 1788307200 | Unix seconds at which the counter resets. |
| X-RateLimit-Reset-At | 2026-09-01T00:00:00.000Z | The same instant, readable. |
| X-RateLimit-Window | month | The quota window. Quotas reset on the first of the month, UTC. |
| Retry-After | 1339200 | Seconds to wait. Sent on a 429. |
On an unmetered key the numeric headers are omitted and X-RateLimit-Policy reads unmetered, so a client parsing these as integers never sees a non-numeric value.
Errors
Errors keep the shape used everywhere else in the API: { ok: false, error, message }, plus extra fields that tell you what to do next. A rejected filter lists the filterable fields, a rejected sort lists the sortable ones, a rejected value lists the allowed values.
| error | Status | Meaning |
|---|---|---|
| validation_error | 400 | A parameter is malformed, unknown, or the wrong type. The message names the field and lists what is valid. |
| sort_not_indexed | 400 | You sorted on a column with no index. The response lists the columns that can be sorted. |
| invalid_cursor | 400 | The cursor was not issued by this API, was edited, or belongs to another resource. |
| cursor_mismatch | 400 | The sort or filters changed since the cursor was issued. Restart the walk without a cursor. |
| offset_too_large | 400 | Offset paging past its ceiling. Use a cursor instead. |
| field_not_entitled | 403 | The field exists but your plan may not receive it. |
| scope_not_allowed | 403 | Your API key does not carry the scope this resource requires. |
| quota_exceeded | 429 | Monthly request quota exhausted. Retry-After tells you when it resets. |
Plan limits
Page size, offset depth, how far updated_since reaches back, and access to fields carrying individual contact data all vary by plan. Your plan is resolved on every request, and when a limit shortens a response we say so in meta rather than trimming quietly. An oversized limit is clamped and reported, not refused.
See plans and pricing for the current numbers, or the full API reference for each endpoint's fields and scopes.