RegistryRadar

API documentation

A read-only JSON API over HTTPS. Every response is JSON; every error carries a machine-readable error field and a sentence explaining what to do about it.

Base URL https://api.registryradar.app · Version prefix /v1 · See plans and limits.

The metered developer endpoints live under /v1/api and are described formally on the API reference, which is also available as an OpenAPI document for code generation. The researched datasets are described on the datasets page.

Quickstart

Three steps, under five minutes. Create an account, create a key in the dashboard \u2014 it is shown once \u2014 then make the calls below. Everything a paying integration uses lives under /v1/api.

1. Confirm the key works

/v1/api/quota is the cheapest call that proves authentication end to end, and it tells you what you have left.

curl
curl -sS "https://api.registryradar.app/v1/api/quota" \
  -H "Authorization: Bearer rr_live_YOUR_KEY"
200
{
  "plan": "starter",
  "environment": "live",
  "metered": true,
  "limit": 25000,
  "used": 683,
  "remaining": 24317,
  "ratePerMinute": 120,
  "resetsAt": "2026-09-01T00:00:00.000Z",
  "scopes": ["records:read", "atlas:read"],
  "declaredPurpose": "child_safety"
}

2. Search a coordinate

Records within two kilometres of a point in Tampa. Check the response before you build against it \u2014 and read covered and withheld, not just the length of records.

curl
curl -sS "https://api.registryradar.app/v1/api/records/search?latitude=27.9506&longitude=-82.4572&radiusMeters=2000&limit=50" \
  -H "Authorization: Bearer rr_live_YOUR_KEY" \
  -H "Accept: application/json"
JavaScript (fetch)
const params = new URLSearchParams({
  latitude: "27.9506",
  longitude: "-82.4572",
  radiusMeters: "2000",
  limit: "50",
});

const res = await fetch(
  `https://api.registryradar.app/v1/api/records/search?${params}`,
  {
    headers: {
      // Never put the key in the URL — the API rejects that with a 400.
      Authorization: `Bearer ${process.env.REGISTRYRADAR_API_KEY}`,
      Accept: "application/json",
    },
  },
);

if (!res.ok) {
  const err = await res.json();
  // `err.error` is a stable code. Branch on it, never on the message.
  throw new Error(`${res.status} ${err.error}: ${err.message}`);
}

const { records, count, covered, withheld } = await res.json();

// An empty list is NOT an all-clear. Say which of the three it is.
if (!covered) console.log("We hold no source covering this point.");
else if (withheld > 0) console.log(`${withheld} records exist but your purpose may not receive them.`);
else console.log(`${count} records.`);
Python (requests)
import os
import requests

res = requests.get(
    "https://api.registryradar.app/v1/api/records/search",
    params={
        "latitude": 27.9506,
        "longitude": -82.4572,
        "radiusMeters": 2000,
        "limit": 50,
    },
    headers={
        # Never put the key in params — the API rejects that with a 400.
        "Authorization": f"Bearer {os.environ['REGISTRYRADAR_API_KEY']}",
        "Accept": "application/json",
    },
    timeout=10,
)
res.raise_for_status()

body = res.json()
print(body["count"], "records;", body["withheld"], "withheld; covered =", body["covered"])
print("quota left:", res.headers.get("X-Quota-Remaining"))

A successful response:

{
  "records": [
    {
      "id": "us-fl-1234567",
      "jurisdiction": "us-fl",
      "displayName": "Doe, John A",
      "address": "1200 E 7th Ave, Tampa, FL 33605",
      "latitude": 27.9601,
      "longitude": -82.4384,
      "riskLevel": "moderate",
      "offenseSummary": "Lewd or lascivious battery",
      "sourceUrl": "https://offender.fdle.state.fl.us/offender/…",
      "lastUpdated": "2026-07-29T04:12:08.000Z"
    }
  ],
  "count": 1,
  "limit": 50,
  "covered": true,
  "withheld": 0,
  "withheldReasons": []
}

3. Fetch the full record

The search shape is deliberately lean \u2014 enough to draw a pin and a row \u2014 so a wide query stays small. Offences, demographics, aliases and registration status come from the detail endpoint, which also returns the source jurisdiction's permitted-use terms alongside the record.

curl
curl -sS "https://api.registryradar.app/v1/api/records/us-fl-1234567" \
  -H "Authorization: Bearer rr_live_YOUR_KEY"

Authentication

Every request carries an API key. Keys are issued per account, prefixed rr_live_ for production and rr_test_ for test. Test keys read the same data, are metered separately and are never billed, so an integration suite cannot run up a bill.

A key is shown once, when it is created. We store a hash of it and never the value, so a lost key is rotated rather than recovered — and a database dump does not hand an attacker working credentials.

Two accepted headers

Prefer the bearer form. X-API-Key is accepted because a great many HTTP clients and no-code tools make the former awkward; the two are equivalent.

Authorization: Bearer rr_live_YOUR_KEY
# or
X-API-Key: rr_live_YOUR_KEY

Never in a query string

A key in the URL is a leak in progress: URLs end up in access logs, browser history, referrer headers and screenshots. The API does not quietly accept one. A request whose URL contains api_key, apikey or token is rejected with 400, even when the key is otherwise valid, so you fix it now rather than after it appears in someone else's logs.

400 — key in query string
{
  "error": "key_in_query_string",
  "message": "Never send an API key in the URL — it leaks into access logs, browser history and referrer headers. Use the Authorization header. Rotate this key: treat it as exposed.",
  "docs": "https://registryradar.app/api/docs#authentication"
}

If you see this, the key has already been written to at least your own logs. Rotate it, then move it to a header.

Scopes and restrictions

Keys carry scopes — records:read and atlas:read by default — and can optionally be pinned to an IP allowlist or a set of origins. A server-to-server key sends no Origin header, so the origin allowlist is empty by default and only worth setting for browser-side use.

Keys are separate credentials from the iOS app's session tokens, checked by separate code against separate tables. An app token cannot mint or use an API key, and an API key cannot reach a user's saved places.

Errors

One envelope, everywhere on this surface \u2014 including validation failures and unhandled faults. error is a stable snake_case identifier: switch on it, and it will not be reworded under you. message is one sentence for a human and may change at any time. docs appears where the fix is not obvious.

Error shape
{
  "error": "invalid_api_key",
  "message": "That key is not valid.",
  "docs": "https://registryradar.app/api/docs#authentication"
}
Every error code returned by the RegistryRadar API, with its cause and the recommended recovery.
StatuserrorCauseRecovery
400key_in_query_stringYour key was found in the URL. Rejected rather than served, and checked before anything else, because URLs reach access logs, browser history and referrer headers.Move the key to a header — and rotate it. It is already in at least your own logs.
400invalid_requestA parameter failed validation. message names the offending field and bound, e.g. latitude must be less than or equal to 90.Fix the parameter. Retrying unchanged fails identically.
401missing_api_keyNo key in either accepted header.Send Authorization: Bearer rr_live_… or X-API-Key.
401invalid_api_keyThe key is not usable. One code covers unknown, revoked and suspended-account deliberately — distinguishing them would turn this into an oracle for probing keys.Check the key is current in the dashboard. If it was revoked, create a new one.
403insufficient_scopeValid key, but it lacks the scope this endpoint needs — records:read or atlas:read.Use a key carrying the scope, or ask us to widen this one.
403ip_not_allowedThe key is pinned to an IP allowlist this call is not on.Add the calling address to the allowlist, or use an unpinned key.
403origin_not_allowedThe key is pinned to specific origins and the request's Origin header is not one of them.Server-to-server calls send no Origin and are unaffected; fix the browser origin list.
403record_withheldThe record exists, but its source jurisdiction does not permit your declared purpose. Deliberately not a 404 — we will not report a record we are holding back as one that does not exist.Nothing to retry. message carries the statute. See declared purpose above.
404record_not_foundNo record with that id, or it is no longer active.Records go inactive when a registry stops publishing them. Re-run the search.
404jurisdiction_not_foundNo US state, territory or tribal registry with that code.List valid codes with GET /v1/api/jurisdictions/us.
404country_not_foundNo safeguarding entry for that ISO 3166-1 alpha-2 code.List valid codes with GET /v1/api/safeguarding.
429rate_limit_exceededYour key exceeded its plan's requests per minute. Your monthly quota is untouched and this call is not billed.Wait the seconds in Retry-After. Retrying sooner does not extend the block.
429quota_exceededThe plan's monthly call quota is spent. Not billed. message carries the exact reset instant.Wait for the reset in X-Quota-Reset, or raise the plan.
500internal_errorOur fault. Not billed, and the quota unit already reserved for the call is refunded.Retry with exponential backoff. If it persists, email support with the time and endpoint.

Retry only 429 and 5xx. Everything else is a request that will fail identically however many times you send it. Neither 429 nor 5xx is billed, and a 5xx refunds the quota unit it had already reserved.

Rate limits and quotas

Two separate controls, both enforced per key, both returning 429 \u2014 with different error codes so you can tell them apart without guessing.

  • Quota \u2014 billable calls per calendar month, reset at 00:00 UTC on the 1st. Exhaustion returns quota_exceeded.
  • Rate \u2014 sustained requests per minute, counted against your key alone. Exceeding it returns rate_limit_exceeded and does not consume quota.

Both are listed per plan on the pricing page, and your own are on GET /v1/api/quota and GET /v1/developers/me.

Headers

Quota and rate-limit response headers.
HeaderMeaning
X-Quota-LimitBillable calls the plan allows this calendar month.
X-Quota-RemainingHow many are left, including the call that returned this header.
X-Quota-ResetSeconds until the monthly counter returns to zero.
X-RateLimit-LimitRequests per minute this key may sustain.
X-RateLimit-RemainingRequests left in the current minute window.
X-RateLimit-ResetSeconds until the minute window rolls over.
Retry-AfterSent only on a 429. Seconds to wait before retrying.

The X-Quota-* headers are absent on unmetered keys \u2014 test keys and negotiated enterprise plans. Treat a missing header as not metered, never as zero; GET /v1/api/quota reports limit: null for the same reason.

RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset are also sent, carrying the monthly quota values. They are deprecated aliases of the X-Quota-* set, kept only for callers already reading them. New code should read X-Quota-*, which cannot be mistaken for the per-minute headers sitting next to it.

429 \u2014 per-minute rate
{
  "error": "rate_limit_exceeded",
  "message": "This key is limited to 120 requests per minute on the starter plan. Retry in 34s. This is the per-minute rate, not your monthly quota — the quota is untouched.",
  "docs": "https://registryradar.app/api/docs#rate-limits"
}
429 \u2014 monthly quota
{
  "error": "quota_exceeded",
  "message": "You have used all 25,000 calls on the starter plan this month. The quota resets at 2026-09-01T00:00:00.000Z. Raise it at https://registryradar.app/api/pricing.",
  "docs": "https://registryradar.app/api/docs#rate-limits"
}

Retrying while rate limited does not extend the block \u2014 refused requests are not counted against the window \u2014 so a client that backs off badly still recovers on schedule. Neither 429 is billed, and neither counts against the quota.

Pagination and result caps

There are no page cursors, and it is worth being plain about that rather than implying one. Record queries are geographic: they take a limit and return the rows inside the area you asked for, up to that cap. Paging through a national result set is not supported.

  • /v1/api/records/search \u2014 limit defaults to 50, maximum 200, ordered by distance from the point. Truncation therefore drops the furthest records first, which is the least-bad thing it could drop.
  • /v1/api/atlas, /v1/api/jurisdictions/us, /v1/api/jurisdictions/tribal, /v1/api/policy and /v1/api/safeguarding \u2014 complete and unpaginated. These are bounded by how many countries and jurisdictions exist, not by traffic, so a cursor would be ceremony over a fixed list. Each returns a count so you can assert you received all of it.

To cover a large area, subdivide it into several bounded searches rather than raising the radius. The radius cap is 80,467 metres (50 miles), and a search that returns exactly limit records has almost certainly hidden some.

Compare count against your limit. When they are equal you are probably looking at a truncated result, and rendering it as complete would understate what is there.

Filtering and sorting

Filtering is per endpoint and deliberately narrow: ?status= on the atlas and ?kind= on safeguarding, each an exact match against a value that appears in an unfiltered response \u2014 so the way to discover the valid values is to fetch the list once and read them. There is no sort parameter anywhere: search is ordered by distance and the reference lists have a fixed order, which means no ordering can change under you between releases.

Endpoint reference

All paths are relative to https://api.registryradar.app. All are GET; the API is read-only.

GET/v1/api/records/search

Registrants within a radius of a coordinate, nearest first. Results are filtered by your key's declared purpose; withheld tells you how many were removed and under which statute.

latitude
Required. −90 to 90.
longitude
Required. −180 to 180.
radiusMeters
Integer metres. 100 to 80467 (50 miles). Default 1609 (one mile).
limit
Integer. 1 to 200. Default 50.

Returns records[], count, limit, covered, withheld, withheldReasons[]. Read covered and withheld before rendering an empty records array.

GET/v1/api/records/{id}

One record in full: address, offences, demographics, aliases, registration status and the link back to the originating registry.

Returns record, usePolicy. 404 record_not_found when it does not exist; 403 record_withheld when it does but your purpose may not receive it — deliberately different answers.

GET/v1/api/quota

What this key has left. The same numbers as the X-Quota-* headers, so you can check before a batch rather than infer it from a call you have already spent.

Returns plan, environment, metered, limit, used, remaining, ratePerMinute, resetsAt, scopes[], declaredPurpose. limit and remaining are null on unmetered keys.

GET/v1/api/status

Dataset sizes computed from what is actually loaded, plus live record freshness. Use it to verify the coverage numbers on our marketing pages rather than taking them on trust.

Returns reference{atlasCountries, usJurisdictions, tribalRegistries, safeguardingCountries}, liveRecords, liveSources, lastSyncedAt, records. liveRecords is null — never 0 — when the record store is unreachable.

GET/v1/api/atlas

One row per country: legal status, operating body, the register's official name, how an ordinary person checks, and the government's own published total where one exists.

status
Optional. Exact match on the status string. Omit for all countries.

Returns countries[], count.

GET/v1/api/jurisdictions/us

All 56 US registries — 50 states, DC and the five territories — with operating body, statute, published registrant count, whether juveniles are included, and the permitted-use position.

Returns jurisdictions[], count. addressPrecision records what the public site PUBLISHES, not what the state knows.

GET/v1/api/jurisdictions/us/{code}

One US jurisdiction in full, by two-letter code. Also resolves tribal registry codes.

Returns jurisdiction, including its usePolicy. 404 jurisdiction_not_found.

GET/v1/api/jurisdictions/tribal

The federally recognised tribes that elected to be SORNA registration jurisdictions in their own right. Those with ownRegistry: true are NOT in the surrounding state's registry.

Returns tribes[], withOwnRegistry, count.

GET/v1/api/policy

The permitted-use position for every jurisdiction we serve, and which of them will serve your key's declared purpose. Check this before you integrate, not after.

Returns yourPurpose, jurisdictions[], count. Each entry carries posture, citation, termsUrl, confidence and servesYourPurpose.

GET/v1/api/safeguarding

For countries with no browsable register, the vetting certificate or barred list that does exist — and crucially who may request it, which is the field most often got wrong.

kind
Optional. Exact match on the scheme kind. Omit for all countries.

Returns countries[], count.

GET/v1/api/safeguarding/{country}

One country's safeguarding entry in full, by ISO 3166-1 alpha-2 code.

Returns country, safeguarding. 404 country_not_found.

GET/v1/api/openapi.json

The machine-readable contract for this API. No key required — you need it before you have one.

Returns An OpenAPI 3.1 document covering /v1/api/* and /v1/developers/*.

Three states, not two

A search that returns no records means one of three different things, and every response tells you which without a second call:

Python \u2014 label an empty result honestly
body = res.json()

if not body["covered"]:
    # We mirror no registry covering this point. Never "no one is here".
    label = "We hold no data for this area."
elif body["withheld"] > 0:
    # Records exist; this key's declared purpose may not receive them.
    cites = ", ".join(r["citation"] for r in body["withheldReasons"])
    label = f"{body['withheld']} records withheld under {cites}."
elif body["count"] == 0:
    label = "No records published for this area by its registry."
elif body["count"] == body["limit"]:
    label = f"{body['count']}+ records — result truncated, narrow the radius."
else:
    label = f"{body['count']} records"

Liveness, without spending quota

GET /healthz needs no key, costs no quota, and returns 200 when the service and its database are up, 503 when the database is unreachable. Point uptime monitoring at that. /v1/api/status answers the different question \u2014 how much data we hold and how fresh it is \u2014 and is metered like any other call.

Permitted use by jurisdiction

Several US jurisdictions limit by statute what their registry data may be used for, and those limits are enforced rather than disclaimed. Each key carries a declared purpose — child safety, employment screening, tenant screening, financial underwriting, research, or platform trust and safety — and records from a jurisdiction that prohibits that purpose are not served to that key.

A filtered response never looks like an empty one. When records are withheld the response says so, with the jurisdiction and the citation:

A filtered record search
{
  "records": [ /* … */ ],
  "withheld": 4,
  "withheldReasons": [
    {
      "jurisdiction": "ca",
      "citation": "Cal. Penal Code § 290.46(j)",
      "note": "California authorises use of its registry information only to protect a person at risk…"
    }
  ],
  "covered": true
}

A non-zero withheld is not an all-clear. It means we hold records for that area that your declared purpose may not receive. Surface it. Rendering a filtered result as a complete one tells your user there is nobody nearby when what actually happened is that we refused to answer.

The position for every jurisdiction is served at GET /v1/api/policy, alongside your own key's declared purpose and whether each jurisdiction serves it. It is an endpoint rather than a clause because getting this wrong is your liability as well as ours. The same table, with the primary-source citation for each jurisdiction, is published on the datasets page.

Four postures, and the differences matter: open means reuse is expressly permitted for any lawful purpose; restricted means permitted but limited — by purpose, by which fields may be republished, or by a notice that must travel with the data; prohibited means we serve nothing from there for any purpose; and unreviewed means we could not retrieve the governing terms, which is a statement about our research rather than about the jurisdiction — and is never treated as permission.

Account and key management

Everything the dashboard does is also an API, so key rotation can be scripted rather than clicked. These endpoints take a developer session token from sign-in \u2014 not an API key, and an API key will not work on them. They are not metered and consume no quota.

Developer platform endpoints for account, key and usage management.
EndpointDoes
POST /v1/developers/signupCreate an account. Returns a session token. Rate limited to 5 per 10 minutes.
POST /v1/developers/signinExchange credentials for a session token. 10 per 10 minutes.
GET /v1/developers/meAccount, plan, usage this period, and every limit the plan carries — including the per-minute rate and live-key cap.
GET /v1/developers/keysList keys. Never returns secrets, only the display prefix.
POST /v1/developers/keysMint a key. The secret is in this response and nowhere else, ever.
DELETE /v1/developers/keys/{id}Revoke a key. Takes effect on its next request.
GET /v1/developers/usageCalls this period, per endpoint with error counts, and per day.
Rotate a key from a script
TOKEN=$(curl -sS -X POST "https://api.registryradar.app/v1/developers/signin" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"…"}' | jq -r .token)

# The secret is returned ONCE. Capture it here or lose it.
curl -sS -X POST "https://api.registryradar.app/v1/developers/keys" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-2026-08","environment":"live"}'

# Revoke the old one only after the new key is deployed and serving.
curl -sS -X DELETE "https://api.registryradar.app/v1/developers/keys/OLD_KEY_ID" \
  -H "Authorization: Bearer $TOKEN"

Versioning and deprecation

The version is in the path. Everything on this page is v1, and v1 does not change shape under you.

What we may change without notice

  • Adding a field to a response object.
  • Adding an endpoint, or an optional parameter.
  • Adding an error code for a condition that previously fell under a broader one.
  • Changing any message string, which is prose for humans and never a contract.
  • Changing the data itself \u2014 record counts, atlas entries and policy postures move as sources and research move.

Parse defensively: ignore fields you do not recognise, and do not treat an unknown error code as a crash.

What counts as breaking

  • Removing or renaming a field, endpoint or parameter.
  • Changing a field's type, or making an optional parameter required.
  • Reusing an existing error code for a different condition.
  • Tightening a validation bound or lowering a maximum.

None of those happen inside v1. They ship as v2, on a new path, and v1 keeps running for at least 12 months after v2 is generally available.

How you will hear about it

Email to every account whose keys called the affected endpoint in the previous 90 days, at least 90 days ahead. A deprecated endpoint or field also returns a Deprecation header and a Sunset header carrying the removal date, so a monitored integration can find out without anyone reading an email.

Currently deprecated: the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset response headers, which carry monthly quota values under names that read as per-second rate limits. Read X-Quota-* instead. They will not be removed before v2.

Reading results safely

Four properties of this data that will otherwise bite you, and which matter more than anything above because they determine whether what you build tells your users the truth.

An empty result is not an all-clear
It means one of three things: the registry publishes nothing for that area, we hold no source for it, or we hold records your declared purpose may not receive. covered and withheld tell them apart in the same response. Never render any of the three as safety.
Null is not zero
Where a count is unknown it is null, never 0. /v1/api/status returns liveRecords: null when the record store is unreachable, /v1/api/quota returns limit: null on an unmetered key, and the atlas returns a null publishedTotal alongside a legal status that remains correct. Rendering any of those as zero asserts we looked and found none.
A published location is not a known location
The atlas records addressPrecision per US registry because several states publish only a ZIP or a block for lower tiers. That describes what the state publishes, not what it knows. Mapping a zip_or_city record to a building puts a pin the state never drew.
The registry is authoritative, not us
Records reflect what a source published when we last synced it and may lag it. Every record carries sourceUrl, and /v1/api/status carries lastSyncedAt. Anything acted on should be verified at source.

Conditions of use

This API is not a consumer report and is not FCRA-compliant; it must not be used for decisions about employment, housing, credit or insurance eligibility. Registry data must never be used to harass, threaten or harm anyone — doing so is a crime, and keys used that way are terminated. See the terms of service.

Something missing or wrong here? Email support@registryradar.app.