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.

ParameterDefaultWhat it does
selectresource defaultComma separated list of fields to return.
sortresource defaultComma separated sort keys. Prefix with - for descending.
limit50Page size, capped by your plan.
offset0Offset paging. Mutually exclusive with cursor.
cursornoneOpaque keyset cursor from a previous response.
countexact, or estimated on very large datasetsexact, estimated, or none.
updated_sincenoneOnly 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]=notnull

Operator reference

OperatorExampleMeaning
eq?county=BrowardEqual. The bare form of any field is an eq filter.
neq?county[neq]=BrowardNot equal. Follows SQL: rows where the column is null are also excluded.
gt / gte?unit_count[gte]=100Greater than, greater than or equal. Numerics and dates.
lt / lte?unit_count[lt]=500Less than, less than or equal.
between?recorded_on[between]=2026-01-01..2026-06-30Inclusive range, written low..high.
in?status[in]=active,expiredAny of. Comma separated, with \, for a literal comma. Up to 200 values.
nin?status[nin]=voidNone 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,GAArray column contains all of these values.
is?website[is]=nullNull 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"] = cursor

Three 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_mismatch instead of a wrong answer. Change the query, start the walk again.
  • cursor and offsetcannot 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=exact runs a real count. exact is 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. exact is false and source says planner_estimate. Free, and on a filtered query it can be off by a wide margin. Fine for a progress bar, not for reconciliation.
  • count=none skips 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 false

Record 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 Modified

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

HeaderExampleMeaning
X-RateLimit-Limit10000Requests included in your plan this month.
X-RateLimit-Used4211Requests consumed this month, including this one.
X-RateLimit-Remaining5789Requests left before you are throttled.
X-RateLimit-Reset1788307200Unix seconds at which the counter resets.
X-RateLimit-Reset-At2026-09-01T00:00:00.000ZThe same instant, readable.
X-RateLimit-WindowmonthThe quota window. Quotas reset on the first of the month, UTC.
Retry-After1339200Seconds 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.

errorStatusMeaning
validation_error400A parameter is malformed, unknown, or the wrong type. The message names the field and lists what is valid.
sort_not_indexed400You sorted on a column with no index. The response lists the columns that can be sorted.
invalid_cursor400The cursor was not issued by this API, was edited, or belongs to another resource.
cursor_mismatch400The sort or filters changed since the cursor was issued. Restart the walk without a cursor.
offset_too_large400Offset paging past its ceiling. Use a cursor instead.
field_not_entitled403The field exists but your plan may not receive it.
scope_not_allowed403Your API key does not carry the scope this resource requires.
quota_exceeded429Monthly 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.