# Build a tenant screening flow: a tenant screening API integration

> For the engineer behind property software: what the records layer covers and what it does not, the fields to collect, the two calls, the async path for a long search, how to read a result, how the rental decision runs as an FCRA order, and what to store.

- **HTML:** https://offendersearch.app/docs/learn/tenant-screening-flow
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What a tenant screening flow built on this API covers, and what it does not

Tenant screening in property software is usually four layers stacked on one application: credit, eviction history, income and identity, and public records. This page builds the public-records layer with the [tenant screening API](/tenant-screening-api): a sex offender registry search across 58 US registries (`POST /v1/search`) and a criminal records search across court, jail, prison and warrant records (`POST /v1/criminal/search`), on one key. Pricing is $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card.

It does not cover the other layers. Credit reports, eviction filings, income and employment verification and identity proofing are other vendors; a flow that needs them calls those vendors beside this one. Say so on screen, because a property manager reading "background check" will assume all four. The [tenant background check](/tenant-background-check) page is the plain-language version of that scope.

Everything below runs on the [sandbox](/sandbox) at `https://sandbox.offendersearch.app` with an `os_test_` key from the dashboard. A test key is free, nothing is billed, and the limits are 60 calls a minute and 2,000 a day per key. The sandbox answers fixed scenarios by name, so every branch of the flow can be exercised before the first real applicant. Production is `https://api.offendersearch.app` with an `os_live_` key and the same shapes. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration.

## The tenant application form: fields the screening calls need

The API identifies an applicant on name and date of birth, and the only automatic identification it makes is `matchState: "dob_match"`, which needs your full date to compare against. The application form has to collect:

- **Legal first name and legal last name**, as separate fields. The registry search is `422` when the query constrains nothing, or only `firstName`, or only `state`; the criminal search requires `lastName` (`422`, code `guard_unbounded_query`).
- **Full date of birth as `YYYY-MM-DD`.** A birth year alone gives `year_match` at best. A malformed date is a `422` with a `{ loc, msg, type }` list and no search.
- **Your applicant id and your property id.** Send the applicant id as `subjectId` and the property (or landlord) id as `clientId` on both calls; `clientId` keeps one property's reviewer clearances from reaching another property's results.
- **Current address, optionally.** The registry query takes `city`, `county`, `state`, `zipcode` and `address`; they give a reviewer context on a name-only lead.

Freeze the screened name and date of birth once the decision is stored. If an applicant corrects them, that is a new screening, not an edit.

## The two tenant screening calls: registry and criminal on one key

Both calls take `{ "query": { firstName, lastName, dob } }`. The registry search also takes `match` (`strict`, `balanced`, `broad`; default `balanced`), `jurisdictions` (state codes, or `null` for all 58 US registries) and `dobTolerance` (0–2, default 0). The criminal search takes `perPage` (1–200) and `page` at the top level. Run them in parallel.

**curl**
```bash
# The applicant screening calls. The sandbox answers fixed scenarios by name; nothing is billed.
export OFFENDERSEARCH_API_KEY=os_test_...   # minted in the dashboard

# 1. Sex offender registry search (58 US registries)
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "match": "balanced", "subjectId": "applicant-8831", "clientId": "property-oakridge"}'

# 2. Criminal records search (lastName required; perPage is top level)
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
       "perPage": 50, "subjectId": "applicant-8831", "clientId": "property-oakridge"}'
```

**Python**
```python
import os, requests

HOST = "https://sandbox.offendersearch.app"          # api.offendersearch.app + an os_live_ key in production
HEADERS = {"X-API-Key": os.environ["OFFENDERSEARCH_API_KEY"], "Content-Type": "application/json"}

def screen_applicant(application: dict) -> dict:
    """application = {id, propertyId, firstName, lastName, dob}. Returns a reviewer-ready summary."""
    person = {"firstName": application["firstName"], "lastName": application["lastName"], "dob": application["dob"]}
    tags = {"subjectId": application["id"], "clientId": application["propertyId"]}

    registry = requests.post(f"{HOST}/v1/search", headers=HEADERS, timeout=130,
                             json={"query": person, "match": "balanced", **tags}).json()
    criminal = requests.post(f"{HOST}/v1/criminal/search", headers=HEADERS, timeout=130,
                             json={"query": person, "perPage": 50, **tags}).json()

    def summarise(r):
        return {
            "recordId": r.get("recordId") or r.get("externalId"),
            "personRef": r["personRef"],
            "matchState": r["matchState"],
            "needsReviewer": r["matchState"] != "dob_match",
            "urls": [s["recordUrl"] for s in r["sources"]],
        }

    incomplete = [s for s in registry["sourceStatus"] if s["incomplete"]]
    return {
        "searchIds": [registry["searchId"], criminal["searchId"]],
        "complete": registry["status"] == "complete" and criminal["counts"]["sourcesIncomplete"] == 0,
        "incompleteSources": [s["source"] for s in incomplete],
        "records": [summarise(r) for r in registry["records"] + criminal["records"]],
        "legalNotice": criminal["legal"]["notice"],
    }

print(screen_applicant({"id": "applicant-8831", "propertyId": "property-oakridge",
                        "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}))
```

**Node**
```javascript
// Node 18+: fetch is built in
const HOST = "https://sandbox.offendersearch.app";   // api.offendersearch.app + an os_live_ key in production
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_API_KEY, "Content-Type": "application/json" };

async function post(path, body) {
  const res = await fetch(HOST + path, { method: "POST", headers, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`${path} ${res.status}: ${await res.text()}`);
  return res.json();
}

export async function screenApplicant({ id, propertyId, firstName, lastName, dob }) {
  const person = { firstName, lastName, dob };
  const tags = { subjectId: id, clientId: propertyId };
  const [registry, criminal] = await Promise.all([
    post("/v1/search", { query: person, match: "balanced", ...tags }),
    post("/v1/criminal/search", { query: person, perPage: 50, ...tags }),
  ]);

  const summarise = (r) => ({
    recordId: r.recordId ?? r.externalId,
    personRef: r.personRef,
    matchState: r.matchState,
    needsReviewer: r.matchState !== "dob_match",
    urls: r.sources.map((s) => s.recordUrl),
  });

  return {
    searchIds: [registry.searchId, criminal.searchId],
    complete: registry.status === "complete" && criminal.counts.sourcesIncomplete === 0,
    incompleteSources: registry.sourceStatus.filter((s) => s.incomplete).map((s) => s.source),
    records: [...registry.records, ...criminal.records].map(summarise),
    legalNotice: criminal.legal.notice,
  };
}

console.log(await screenApplicant({ id: "applicant-8831", propertyId: "property-oakridge",
                                    firstName: "Exact", lastName: "Match", dob: "1980-01-01" }));
```

On the sandbox, "Exact Match" with `dob` `1980-01-01` returns one registry record at `dob_match` (OH, two addresses, a photo, "Sexual battery", Tier III). "Felony Record" with `dob` `1975-06-15` returns one `court_case` with a felony burglary charge at `disposition: "Guilty"`, a dismissed misdemeanor theft charge, a sentence and `matchState: "dob_match"`. "Clear Visitor", "Clear Record" and any name that is not a scenario, such as Jordan Rivera, 1988-04-12, return a complete answer with zero records.

## Long tenant screening searches: async POST /v1/searches with a webhook

A cached registry search answers in one request. A search that asks a jurisdiction to re-verify a record live, with a `live` block, can take longer than a web request should wait, and a screening queue that holds a worker open per applicant is the wrong shape. `POST /v1/searches` takes the same body as `POST /v1/search` plus an optional `webhookUrl` and answers `202` at once.

*Submit, 202, then webhook or poll*
```bash
# A long registry search: submit, get a 202 at once, receive the webhook when it finishes.
curl -X POST https://sandbox.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "match": "balanced",
       "webhookUrl": "https://hooks.example.com/screening/applicant-8831?token=a-secret-you-generated"}'

# 202
{
  "searchId": "srch_…",
  "status": "pending",
  "jurisdictionsQueried": 58,
  "estimatedCompletionSec": 20,
  "resultsUrl": "https://sandbox.offendersearch.app/v1/searches/srch_…"
}

# Persist searchId before anything else. Then either wait for the POST to webhookUrl, or poll:
curl https://sandbox.offendersearch.app/v1/searches/srch_… -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
# → the same envelope as POST /v1/search once status is "complete"
```

The `202` carries `searchId`, `status: "pending"`, `jurisdictionsQueried`, `estimatedCompletionSec` and `resultsUrl`. Persist the `searchId` before anything else: there is no idempotency header on the search endpoints, so a resubmitted search is a second billed search. When the search finishes the platform POSTs to `webhookUrl`; the token in that URL is the authentication for this channel, so generate it per applicant. If the webhook never arrives, `GET /v1/searches/{searchId}` returns the same envelope as a synchronous search once `status` is `complete`. The [async and webhooks reference](/docs/async-and-webhooks) covers timing and retries; a criminal live search with more than two sources answers `202` with a `poll` URL in the same way.

## Reading a tenant screening result: matchState and sourceStatus

Two fields decide what the flow does next. `matchState` on each record says how well the applicant's date of birth was verified; `sourceStatus[]` on the envelope says whether each registry was searched to the end.

| matchState | Meaning | Flow |
| --- | --- | --- |
| `dob_match` | Your full date agrees with the record: the only automatic identification. | Identified. Goes to the decision step. |
| `year_match` | The registry publishes a year only and it agrees. | Reviewer. |
| `age_match` | Only an age is published; it agrees within `ageTolerance`. | Reviewer. |
| `no_dob_age_year` | Nothing to verify against; the name matched. `unverified: true`. | Reviewer. Never an automatic decision. |
| `dob_near_match` | Only with `dobTolerance`; a typo-distance date. Always unverified. | Reviewer, with the date difference shown. |
| `null` | No `dob` or `age` was sent. | Fix the form; re-run. |

A record whose date of birth contradicts yours is not returned at all: "Exact Match" with `dob` `1990-05-05` is zero records on the sandbox. So the reviewer only ever sees records that could be the applicant. Show them anything weaker than `dob_match`, with `matchBasis`, `matchedName` (a record matched through an alias has `matchedName.type: "alias"` and `"alias:exact"` in `matchBasis`) and each `sources[].recordUrl` open beside it. The [matching reference](/docs/matching) explains each value.

On the envelope, `status: "partial"` or any `sourceStatus[]` entry with `incomplete: true` means a registry was not searched to the end; `incompleteReason` says why. That applicant is not clear. Queue a re-run, or send `onDeadline: "error"` so a deadline is a `504` instead of a shorter answer. On the criminal side `counts.sourcesIncomplete` is the same signal, and `counts.sourcesPending` reports sources in scope that are not available yet.

## The rental decision in a tenant screening flow: the FCRA order path

Every standard response carries `legal.notice`: "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." A decision to rent or not rent to an applicant is the kind of decision that can be a consumer-report use. When that is what the flow decides, the decision step runs through [FCRA Partner Access](/fcra), not the standard endpoints.

*End user, then order*
```bash
# The rental decision runs as an FCRA order (approved partners; the sandbox runs the same flow).
# 1. Register the landlord or property manager as the end user, once.
curl -X POST https://sandbox.offendersearch.app/v1/fcra/end-users \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"permissiblePurposes": ["tenant"]}'
# → the endUserId used below

# 2. Create the order. Idempotency-Key makes a retried POST return the original order.
curl -X POST https://sandbox.offendersearch.app/v1/fcra/orders \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: applicant-8831-2026-11-03" \
  -d '{"endUserId": "eu_…",
       "permissiblePurpose": "tenant",
       "consent": {"obtainedAt": "2026-11-03T14:02:00Z", "method": "application_form",
                   "documentRef": "app-8831-consent.pdf"},
       "subject": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01",
                   "addresses": [{"line1": "12 Elm St", "city": "Sampleville", "state": "OH", "zipcode": "00000"}]},
       "useLocation": {"state": "OH"},
       "searches": [{"type": "sex_offender"}, {"type": "criminal", "exclusions": "all"}],
       "clientReference": "applicant-8831",
       "callbackUrl": "https://hooks.example.com/fcra"}'

# 202 → status "received". Then:
#   GET /v1/fcra/orders/{order_id}                       until status is "complete"
#   GET /v1/fcra/orders/{order_id}/searches/{search_id}  each search's results
#   GET /v1/fcra/orders/{order_id}/report                the report
#   GET /v1/fcra/orders/{order_id}/adverse-items         what an adverse action may cite
# Every order carries consumerPortal { url, accessCode } for the applicant.
```

The order body is `endUserId` (the landlord or manager registered with `POST /v1/fcra/end-users`), `permissiblePurpose: "tenant"`, a `consent { obtainedAt, method, documentRef }` block recording the applicant's authorisation, the `subject` (`firstName`, `lastName`, `dob`, `aliases[]`, `addresses[]`), `useLocation { state }`, `liveScope`, `searches`, `clientReference` and a `callbackUrl`. `Idempotency-Key` is replayed for 24 hours: the same body returns the original order with `Idempotent-Replayed: true`; a different body under the same key is `409 idempotency_conflict`. The order answers `202` at `received`, reaches `complete`, and the report, adverse items, adverse actions, disputes and the consumer portal hang off `order_id`.

This page describes what those endpoints do. Which of your decisions must run as an order, and what your notices must say, is a question for counsel. The [FCRA orders reference](/docs/fcra/orders) documents every field; the [FCRA tenant screening](/fcra-tenant-screening) page describes the tenant flow as a product.

## After the tenant screening decision: store citations, monitor residents

Store, keyed by applicant and property: both `searchId` values (and the FCRA `order_id` when there is one), the decision and who made it, and for each record its `recordId` (criminal records carry `externalId` and `personRef`), `personRef`, `matchState` and every `sources[].recordUrl`, plus the `legal.notice` text. `recordId` re-fetches the record by id; `personRef` is stable across searches and monitor alerts, so a later alert can be matched to a record a reviewer already assessed; `recordUrl` is the registry's or court's own page. `POST /v1/report` renders a verification PDF for a registry search when a document is needed.

After move-in, a person monitor watches each resident continuously and sends a signed webhook when a record appears, changes or is removed. `POST /v1/monitors` with `product`, `type: "person"`, `person { firstName, lastName, dob }`, `minConfidence`, `label` and `channels { webhookUrl }` answers `201` with `channels.webhookSecret` shown once. The price is $3 a month per person monitor, $10 per location monitor. A location monitor (`type: "location"`, `location { address, lat, lng, radiusMiles }`) watches the property rather than a person; `product: "criminal"` with `type: "location"` answers `200 { status: "not_yet_available" }` and creates nothing.

*Monitor a resident*
```bash
# Watch a resident after move-in. $3 a month per person monitor; the webhook secret is shown once.
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product": "sex-offender", "type": "person",
       "label": "property-oakridge / unit 4B",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "minConfidence": "dob_match",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
# 201 … "channels": { "webhookSecret": "whsec_…" }   ← store it with the lease
# Delete at move-out: DELETE /v1/monitors/{monitorId}
```

The alert body is `{ type: "monitor.alert", event, alertId, monitorId, monitorLabel, product, detail { name, jurisdiction, locality, matchLabel, matchConfidence, personRef, recordId }, occurredAt, sentAt }`, signed with `X-Offendersearch-Signature: t=<unix>,v1=<hex>` over `"<t>." + raw body`. Verify in constant time inside the five-minute window before parsing; the [webhook example](/docs/learn/webhook-example) is the receiver and the [person monitoring reference](/docs/monitoring/person-monitoring) covers lifecycle and deletion. An alert goes to the same reviewer queue as a screening lead. Monitoring is not a consumer report; a resident decision that follows from an alert is subject to the same FCRA question as the original one.

## Build the tenant screening flow in seven steps

1. **Mint a test key.** Create an os_test_ key in the dashboard and call https://sandbox.offendersearch.app. A test key is free and nothing is billed.
2. **Collect the applicant fields.** Take legal first name, legal last name and a full YYYY-MM-DD date of birth; send your applicant id as subjectId and your property id as clientId.
3. **Run the two screening calls.** POST /v1/search and POST /v1/criminal/search with the same query; read status, counts, sourceStatus[] and each record's matchState and sources[].
4. **Use the async path for a long search.** POST /v1/searches with a webhookUrl answers 202 with searchId and resultsUrl; persist the searchId, then wait for the webhook or poll.
5. **Route by matchState.** Treat dob_match as identified; show a reviewer every weaker record and every search with an incomplete source; never mark those clear.
6. **Run the rental decision as an FCRA order.** POST /v1/fcra/orders with permissiblePurpose tenant, consent, subject and searches through FCRA Partner Access; consult counsel.
7. **Store citations and monitor residents.** Save searchIds, recordId or externalId, personRef, matchState and sources[].recordUrl with the decision; create a person monitor per resident.

The [tenant screening API](/tenant-screening-api) page describes where this flow sits in a leasing product.

## Tenant screening API questions

### Does the tenant screening API return credit or eviction history?

No. It is the public-records layer: sex offender registries and criminal records (court cases, jail bookings, prison records, warrants). Credit, eviction filings, income and identity proofing come from other vendors.

### Do I need the applicant's full date of birth?

For an automatic identification, yes. `dob_match` is the only `matchState` that confirms identity and it needs your full `YYYY-MM-DD` date. A year gives `year_match` at best; no date gives `matchState: null` on every record.

### When should I use POST /v1/searches instead of POST /v1/search?

When the search may take longer than your worker should wait: a search with a `live` block, or any flow where screening runs in a queue and a webhook is the natural completion signal. The body is the same; the `202` gives you `searchId` and `resultsUrl`.

### What does a reviewer see?

Every record weaker than `dob_match`, with `matchState`, `matchBasis`, `matchedName` and `sources[].recordUrl`, plus every search where a `sourceStatus[]` entry is incomplete. A `dob_match` record is identified; what happens next is the decision step.

### Is the standard search enough for a rental decision?

The standard response says "Not a consumer report" in `legal.notice`. A rental decision that is a consumer-report use runs as an FCRA order with `permissiblePurpose: "tenant"`. Whether yours is one is a question for counsel.

## What this page does not cover

- Credit, eviction, income and identity layers: other vendors, called beside this one.
- The adverse-action notice sequence, disputes and the consumer portal.
- Live re-verification of a record with a jurisdiction (`live` block, $0.02 per completed source).
- Rate limits and retry schedules.

## Related

- [Background check API (the two-dataset hub)](https://offendersearch.app/background-check-api)
- [FCRA adverse action API tutorial](https://offendersearch.app/docs/learn/fcra-adverse-action-api)
- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Monitor a person with webhooks](https://offendersearch.app/docs/learn/monitor-a-person-with-webhooks)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Date of birth matching](https://offendersearch.app/docs/date-of-birth)
- [Result completeness](https://offendersearch.app/docs/result-completeness)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Batch searches](https://offendersearch.app/docs/batch)
- [Live evidence](https://offendersearch.app/docs/live-evidence)
- [FCRA docs](https://offendersearch.app/docs/fcra)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
