Pagination & response size
Top-level page and perPage, a 200-record page cap, and counts.records as the true total.
Base URL https://api.offendersearch.apppage and perPage are top-level
page and perPage are top-level request fields, not inside query — a deliberate difference from the Sex Offender API. Sending them inside query silently gives you the defaults (page: 1, perPage: 50); nothing errors, so the mistake is quiet. They are echoed back at the top level exactly as resolved (after clamping), so read page and perPage from the response rather than assuming them.
| Parameter | Where | Default | Constraint |
|---|---|---|---|
page | request top level | 1 | >= 1 |
perPage | request top level | 50 | clamped to 1–200 |
page/perPage inside query are ignored — you get page 1 of 50 and it looks like there is nothing more to fetch.// ✗ WRONG — page/perPage inside query are ignored; you get page 1, perPage 50
{ "query": { "lastName": "Hamilton", "state": "TX", "page": 3, "perPage": 200 } }
// ✓ RIGHT — page/perPage at the top level
{ "query": { "lastName": "Hamilton", "state": "TX" }, "page": 3, "perPage": 200 }The per-page cap is 200
One response carries at most 200 records. A perPage above 200 is clamped to 200; a perPage below 1 is clamped to 1. The value is echoed back after clamping, so a request for perPage: 300 comes back reporting perPage: 200 — read the echo, do not assume your value survived.
// perPage 300 → clamped to 200 (echoed back as 200); perPage 0 → clamped to 1
{ "page": 1, "perPage": 200, "totalPages": 3, "counts": { "records": 512 } }perPage slices a result you can walk in full. Read counts.records against counts.recordsReturned: if they differ, there is more to fetch. See counts.counts.records is the total; records.length is the page
The two are different numbers and confusing them is the second common mistake:
| Field | Meaning |
|---|---|
counts.records | The total matched, before your page slice. It does not change as you page. Use it for "N results found". |
counts.recordsReturned | How many records this response carries — the current page. Equals records.length. |
totalPages | ceil(records / perPage), minimum 1. Authoritative — you do not compute it yourself. |
Behaviour
counts.recordsis the true total and does not change as you page. Walkpage = 1 … totalPagesto retrieve the whole result set; each record appears exactly once.- Ordering is total and deterministic, so paging never reshuffles — two identical requests return the same records on the same pages. Records sort by
matchConfidence(descending), then surname, then first name, thenexternalIdas the final stable tie-break. On a merged cached + live result the same key is applied after the merge. See Matching & confidence. - A page past the end returns an empty
recordsarray —pageechoes the number you asked for,counts.recordsandtotalPagesare unchanged, andrecordsReturnedis0. Sowhile (records.length) page++terminates.
// A 512-record result at perPage 200 spans 3 pages (ceil(512 / 200)).
// page 1 — full
{ "page": 1, "perPage": 200, "totalPages": 3,
"counts": { "records": 512, "recordsReturned": 200 } }
// page 3 — the remainder
{ "page": 3, "perPage": 200, "totalPages": 3,
"counts": { "records": 512, "recordsReturned": 112 } }
// page 99 — past the end: an empty slice, totals unchanged
{ "page": 99, "perPage": 200, "totalPages": 3,
"counts": { "records": 512, "recordsReturned": 0 }, "records": [] }Worked example — collecting a whole result set
Because totalPages is authoritative and ordering is stable, the safe loop is "fetch page 1, then walk to totalPages". Note that page/perPage ride at the top level of the body — the one detail this loop exists to get right:
async function page(n) {
const res = await fetch("https://api.offendersearch.app/v1/criminal/search", {
method: "POST",
headers: {
"X-API-Key": process.env.OFFENDERSEARCH_KEY,
"Content-Type": "application/json",
},
// ★ page / perPage are TOP-LEVEL — NOT inside query.
body: JSON.stringify({
query: { lastName: "Hamilton", state: "TX" },
page: n,
perPage: 200,
}),
});
return res.json();
}
const first = await page(1);
// counts.records is the TOTAL before the slice; totalPages is authoritative.
const all = [...first.records];
for (let n = 2; n <= first.totalPages; n++) {
const next = await page(n); // next.page echoes the page you got
all.push(...next.records); // each record appears exactly once
}
// all.length === first.counts.recordsLive searches page identically — the merged (cached + live) result is ordered by the same total, stable key, so a live answer walks page by page just like a cached one. On a live search, still read counts.sourcesIncomplete before treating an empty page as a confirmed absence; paging tells you how many records there are, not whether every source was searched to the end. See Result completeness.