Offendersearch
Criminal API Reference

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

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

ParameterWhereDefaultConstraint
pagerequest top level1>= 1
perPagerequest top level50clamped to 1–200
The "inside query" gotcha. This is the single most common paging mistake against this endpoint. page/perPage inside query are ignored — you get page 1 of 50 and it looks like there is nothing more to fetch.
Where page / perPage go
// ✗ 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 is clamped to 1–200
// perPage 300 → clamped to 200 (echoed back as 200); perPage 0 → clamped to 1
{ "page": 1, "perPage": 200, "totalPages": 3, "counts": { "records": 512 } }
A cap is not a page. The 200-record ceiling is a slice size, not a limit on the answer — 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:

FieldMeaning
counts.recordsThe total matched, before your page slice. It does not change as you page. Use it for "N results found".
counts.recordsReturnedHow many records this response carries — the current page. Equals records.length.
totalPagesceil(records / perPage), minimum 1. Authoritative — you do not compute it yourself.

Behaviour

  • counts.records is the true total and does not change as you page. Walk page = 1 … totalPages to 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, then externalId as 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 records array page echoes the number you asked for, counts.records and totalPages are unchanged, and recordsReturned is 0. So while (records.length) page++ terminates.
Walking a 512-record result
// 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:

Paging a cached result set
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.records

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