Developer Platform
Progressive loading, fast rows, and exact counts
Render authorized rows immediately, then fill in an independently requested full count without confusing a browse limit with the size of an organization.
Contacts and Global Search now separate interactive row retrieval from full authorized counting. A large exact count must not prevent useful rows, navigation, or unrelated page sections from appearing.
Progressive loading is not a streaming list endpoint
Each list/count request returns an ordinary JSON response. The native React page progressively fills independent sections as their requests finish; it does not receive individual records over SSE, NDJSON, or WebSocket. MCP Streamable HTTP is a separate transport and does not make these REST list responses streaming.
Fast defaults and the browse window
includeTotal defaults to false on Contacts and Global Search. Use items and hasMore to render and navigate. A list total with totalIsExact=false is not the organization's full total. The response's browseLimit is currently 5,000: refine the search or filters to reach records beyond that interactive window. The limit does not delete records, reduce storage, or cap the exact count. Other modules retain their own published pagination contracts.
For example, an authorized count of 40,000 should be shown as “40,000 matching records; browse up to 5,000—refine your search,” not “5,000 records.” Behavior change since 1.20.12: hasMore=false or an empty page on these module routes can mean the browsing window ended, not that every matching record was read. A separate exact count does not lift the row cap. Existing sync/export loops using these module routes must migrate to the Records enumeration path below.
Search the whole authorized directory, then page the matches
The Contacts API applies search before pagination and the browse limit. A contact outside the first 5,000 unfiltered rows can still be found by a matching name, company, email or phone. Keep the same search and filters on every page, and reset pageNumber to 1 when they change.
GET /v1/apps/{saasAppId}/modules/contacts?search=smith&pageNumber=1&pageSize=50
GET /v1/apps/{saasAppId}/modules/contacts?search=smith&pageNumber=2&pageSize=50
GET /v1/apps/{saasAppId}/modules/contacts/count?search=smith
These are authenticated trusted-server requests. URL-encode search values. The count reports all authorized matches for “smith”; if that search itself has more than 5,000 matches, make it more specific. Page-number traversal is not a frozen snapshot: concurrent edits can shift rows between pages.
Enumerate every authorized contact
For full contact enumeration, use POST /v1/apps/{saasAppId}/objects/contacts/records/search with records.read, a body such as {"page":1,"pageSize":100,"sort":"created_asc"}, and follow that operation's hasMore. Increment page (not pageNumber) while keeping filters and identity unchanged. This generic Records operation has no 5,000-row cap. Its response contains records, common record metadata and permitted custom fields, not native Contacts rows.
For native email/phone fields, hydrate returned IDs through GET /v1/apps/{saasAppId}/modules/contacts/{recordId} with modules.contacts.read. Each read reauthorizes access; bound concurrency and handle records removed or access revoked between requests. The kit README and examples/enumerate-contacts.mjs provide the SDK iterator and executable tests.
This is numbered-page enumeration, not a snapshot/export job or incremental change feed. Concurrent writes or authorization changes can shift pages and cause omissions; a stable sort and deduplication alone do not fix that. Use a quiescent source window and reconcile completeness when required. Deep paging and per-record hydration have their own costs.
Dedicated count endpoints and scopes
| Public route | SDK method | Required scope |
|---|---|---|
GET /v1/apps/{saasAppId}/modules/contacts/count | contactsCount | modules.contacts.read |
GET /v1/apps/{saasAppId}/modules/global-search/count | globalSearchCount | modules.global-search.read |
Native authenticated tenant routes are /api/modules/contacts/count and /api/modules/global-search/count. Counts return contractVersion, totalRecords, totalIsExact=true, and browseLimit. They do not accept paging arguments. Copy the same supported filters from the row request: Contacts uses search, contactType, repeated contactTypeId, and locationId; Global Search uses its declared query, moduleKey, and locationId.
Use rows and counts separately
Omit includeTotal on list requests. Pre-launch breaking change, live in server 1.20.21: legacy includeTotal=true requests return HTTP 400 with contacts_count_endpoint_required or global_search_count_endpoint_required. The normal validation envelope includes code, a safe error message directing you to the matching count endpoint, and correlationId. Do not retry the rejected list request unchanged: fetch rows without the parameter and call the dedicated count endpoint with the same filters and identity. Developer Kit 1.20.18 removes the parameter from OpenAPI and the SDK; older clients explicitly sending false remain accepted. An authenticated public check verified both errors, both count endpoints and omitted/false lists on September 23, 2026.
Render rows independently. If the separate count request times out or is unavailable, retain the rows with totalIsExact=false and show “Total temporarily unavailable,” not zero or a failed page. A successful count can update the displayed total without refetching rows. The read-only Global Search MCP tool uses the fast default. MCP access separately requires mcp.use and the tool's module scope; a REST read credential alone does not grant MCP discovery.
Counts obey the same security boundary as rows
A count is limited to the verified app, organization, user, DataRole, record participation and location visibility. It is not a tenant-wide aggregate. Supplying an appAccountId or userId cannot replace the verified identity. A supported location filter can narrow an authorized set, never expand it. No visible records produces zero; denial or a failed request must not be displayed as zero. A read-only credential can count but cannot write.
Use the executable SDK example
Kit 1.20.71 includes examples/progressive-contacts.mjs and its offline regression tests. Run the SDK on your trusted backend and forward authorized results through your existing application channel. The helper starts the two requests together and invokes independent callbacks; it does not expose a new BuildWithHQ endpoint.
import { loadContactsProgressively } from "./examples/progressive-contacts.mjs";
const abort = new AbortController();
const work = loadContactsProgressively(api, {
filters: { search: "Ada" }, pageSize: 50, signal: abort.signal,
onRows: page => sendAuthorizedRowsToYourView(page),
onCount: count => updateYourViewTotal(count.totalRecords),
onCountError: () => showYourViewCountUnavailable(),
});
// Your application supplies the callbacks above; rows do not wait for count.
await Promise.all([work.rows, work.count]);
// On navigation, identity or filter changes: abort.abort().
node --test examples/progressive-contacts.test.mjs
App credentials belong on trusted servers, never in browser bundles, page JSON, local storage, prompts or logs. Use the documented delegated-user flow when acting as an end user.
Cache and consistency boundaries
The native UI keeps display-count snapshots in session memory with a 60-second freshness limit, bounded requests and invalidation after relevant mutations/session changes. It is not a permanent session total and there is no server-side count cache. Rows and count are separate reads, not one transactionally consistent snapshot; concurrent writes can make them differ temporarily.
For your own cache, include the verified identity and every filter, clear it on logout/account/permission changes, and discard late responses from an abandoned view. Do not share counts across organizations or credentials. Keep rows usable if the count fails; offer a bounded retry rather than issuing a count for every page. Counts are int64 in the API: JavaScript clients must reject unsafe integers or use a lossless parser rather than silently round an exact total.
Operational expectations
Exact counts can still be expensive. Give them a separate timeout/error state, bound concurrency, honor Retry-After, and capture the server-issued correlation ID. Local load measurements are not a production SLA. Establish latency targets, monitoring and capacity for your actual data and authorization shape before launch.
Next: pagination and retries, enterprise integration readiness, and contract/version pinning.
Capability review: 2026-09-14. See current features and setup requirements, the generated API Map and first-class module inventory.