Support/Developer Platform/Progressive loading, fast rows, and exact counts

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.

Default
Fast rows; no exact count
Interactive browse
5,000 matches
Exact count
Separate opt-in request

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. includeTotal=true changes the count, not 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 routeSDK methodRequired scope
GET /v1/apps/{saasAppId}/modules/contacts/countcontactsCountmodules.contacts.read
GET /v1/apps/{saasAppId}/modules/global-search/countglobalSearchCountmodules.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.

includeTotal=true remains available on list requests when one combined response is needed, but waits for the exact count. It is not the recommended interactive default. The read-only Global Search MCP tool inherits 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.13 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
Important

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. For exact current technical availability, use the generated API Map and first-class module inventory.