Developer docs · v1.0.0

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.

Updated November 3, 2026

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: 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 page is the plain-language version of that scope.

Everything below runs on the 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. 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.

# 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"}'

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

matchStateMeaningFlow
dob_matchYour full date agrees with the record: the only automatic identification.Identified. Goes to the decision step.
year_matchThe registry publishes a year only and it agrees.Reviewer.
age_matchOnly an age is published; it agrees within ageTolerance.Reviewer.
no_dob_age_yearNothing to verify against; the name matched. unverified: true.Reviewer. Never an automatic decision.
dob_near_matchOnly with dobTolerance; a typo-distance date. Always unverified.Reviewer, with the date difference shown.
nullNo 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 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, not the standard endpoints.

End user, then order
# 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 documents every field; the 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
# 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 is the receiver and the person monitoring reference 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 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.