# RESTful API example: one request, one response, explained

> Most "REST API example" pages stop at GET /users/123. This one runs a real request against a real public-records API, shows the real response, and explains every field — then builds the client.

- **HTML:** https://offendersearch.app/docs/learn/restful-api-example
- **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 RESTful API looks like

A RESTful API exposes **resources** at URLs and lets a client act on them with HTTP methods. The resource is a noun (a search, a record, a monitor); the method is the verb (`POST` creates, `GET` reads, `DELETE` removes); the body and the response are JSON. That is the whole idea. Everything else — authentication, versioning, errors, pagination — is convention layered on top, and the conventions are what this page shows with a working example.

The API used throughout is the [OffenderSearch background check API](/background-check-api): one request searches 58 US sex offender registries (or, on a sibling endpoint, criminal records) and returns the matching people as JSON. It is a good teaching API because the response is rich enough to be interesting and the [sandbox](/sandbox) is free, so every example here is one you can run.

| REST idea | What it means here | Example |
| --- | --- | --- |
| Resource | A thing with an identity and a URL | `/v1/searches/{searchId}`, `/v1/monitors/{monitorId}` |
| Method | The action on the resource | `POST /v1/search` creates a search; `GET /v1/searches/{id}` reads it; `DELETE /v1/monitors/{id}` removes a monitor |
| Representation | The JSON you send and receive | the request body `{"query": {…}}`, the response envelope `{"status", "counts", "records"}` |
| Status code | Whether it worked, in one number | `200` ok, `201` created, `202` accepted (async), `401`, `422`, `429` |
| Statelessness | Every request carries everything the server needs | the key in `X-API-Key` on every call; no session |

## API endpoint example: the URL, piece by piece

An **API endpoint** is a method plus a URL. The endpoint example this page uses is `POST https://sandbox.offendersearch.app/v1/search`. Read it from left to right:

| Part | Value | Why it is there |
| --- | --- | --- |
| Method | `POST` | A search is created, not fetched: the query carries a person's name and date of birth, which must never sit in a URL where proxies and logs keep it. See [API key vs OAuth](/docs/learn/api-key-vs-oauth) for the same logic applied to credentials. |
| Scheme + host | `https://sandbox.offendersearch.app` | TLS always. The sandbox host answers test keys (`os_test_…`) and bills nothing; `https://api.offendersearch.app` answers live keys (`os_live_…`). Same paths, same shapes. |
| Version | `/v1` | Every path starts with the API version, so a breaking change can ship as `/v2` without touching your `/v1` integration. |
| Resource path | `/search` | The collection the request acts on. `/criminal/search` is the criminal-records sibling; `/monitors` is the monitoring resource. |

There is no query string on a search because the query is a JSON body. Endpoints that read a resource put the id in the path (`/v1/searches/srch_…`), never in a query parameter — that keeps URLs cacheable and ids out of referrer headers. The full endpoint list is on the [API reference](/docs/api-reference).

## API request example in cURL, Python and Node

Three identical requests. The header `X-API-Key` authenticates (see [API token example](/docs/learn/api-token-example) for how to get one); `Content-Type: application/json` tells the server how to read the body. The test name **Exact Match** with date of birth **1980-01-01** is a fixed sandbox person, so the response is the same every time — a real request, a deterministic answer.

**cURL**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}}'
```

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

BASE = "https://sandbox.offendersearch.app"          # os_test_… keys only; nothing is billed
KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]          # os_test_…

r = requests.post(
    f"{BASE}/v1/search",
    headers={"X-API-Key": KEY, "Content-Type": "application/json"},
    json={"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}},
    timeout=60,
)
r.raise_for_status()
data = r.json()
print(data["status"], data["counts"]["records"])          # complete 1
for rec in data["records"]:
    print(rec["matchConfidence"], rec["matchState"], rec["registrationState"])
```

**Node**
```javascript
const BASE = "https://sandbox.offendersearch.app";   // os_test_… keys only; nothing is billed
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;      // os_test_…

const res = await fetch(`${BASE}/v1/search`, {
  method: "POST",
  headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ query: { firstName: "Exact", lastName: "Match", dob: "1980-01-01" } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data.status, data.counts.records);            // complete 1
for (const rec of data.records) console.log(rec.matchConfidence, rec.matchState, rec.registrationState);
```

> **Get a test key in a minute.** Create a free account at [/sign-up](/sign-up) (no card), open Dashboard → API keys, and create a **test** key. Test keys start with `os_test_`, work only on the sandbox host, and are never billed. The sandbox allows 60 requests a minute and 2,000 a day per key.

## The API request body: a JSON schema you can read

The **request body** is one JSON object with a `query` object inside it. Wrapping the query in a named object is deliberate: the same envelope later carries optional blocks (`live`, `proof`, `include`) without changing the shape of what you already send.

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `query.firstName` | string | yes | Given name. Matching is case-insensitive and tolerant of common variants. |
| `query.lastName` | string | yes | Family name. Both names are required: the API refuses a one-name search with a `422` rather than return thousands of weak matches. |
| `query.dob` | string `YYYY-MM-DD` | no | Date of birth. When present, every match is verified against it and labelled (`matchState`). A contradicting date removes the match. |
| `query.state` | string, 2 letters | no | Limit to people registered in, or living in, one state. |
| `query.city`, `query.zipcode` | string | no | Narrow by an address on file. |

Validation is strict and the error names the field (see [the error shape](#api-errors-and-status-codes) below). The exact JSON schema is published in the [OpenAPI document](/openapi.json), which is also what the [Postman collection](/docs/learn/postman-api-documentation) and the [SDKs](/sdk) are generated from.

## API response example: the JSON, field by field

This is the response to the request above, as the sandbox returns it (the sandbox adds `"sandbox": true`; production omits it). It is a **response envelope**: metadata about the search first, the matching records last.

*200 OK*
```json
{
  "searchId": "srch_7c1e9a2b4f5d6e8091a2",
  "status": "complete",
  "elapsedMs": 118,
  "sandbox": true,
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0 },
  "warnings": [],
  "sourceStatus": [
    { "source": "OH", "status": "ok", "matched": 1, "fromCache": true, "incomplete": false, "incompleteReason": null }
  ],
  "records": [
    {
      "recordId": "rec_0a1b2c3d4e5f60718293",
      "matchConfidence": 0.96,
      "matchBasis": ["lastName:exact", "firstName:exact", "dob"],
      "matchState": "dob_match",
      "name": { "first": "Exact", "middle": "Sample", "last": "Match", "suffix": "", "full": "Exact Sample Match" },
      "dob": "1980-01-01",
      "dobPrecision": "exact",
      "registrationState": "OH",
      "addresses": [
        { "type": "residence", "line1": "100 Sample St", "city": "Sampleville", "county": "Sample", "state": "OH", "zipcode": "00000" },
        { "type": "employment", "line1": "200 Example Ave", "city": "Sampleville", "county": "Sample", "state": "OH", "zipcode": "00000" }
      ],
      "offenses": [
        { "crime": "Sexual battery", "statute": "SAMPLE 2907.03", "tier": "Tier III", "convictionDate": "2004-05-12", "registrationDate": "2008-02-01" }
      ],
      "sources": [
        { "jurisdiction": "OH", "registryName": "Ohio eSORN", "recordUrl": "https://…", "lastCheckedAt": "2026-10-27T03:10:00+00:00" }
      ]
    }
  ]
}
```

| Field | Read it as |
| --- | --- |
| `searchId` | The id of the search resource that was just created. Keep it: `GET /v1/searches/{searchId}` returns this same envelope later. |
| `status` | `complete` when every source answered; `partial` when one did not (then read `sourceStatus[]`). An empty `records[]` is only a "no match" when `status` is `complete`. |
| `counts` | How many people matched, how many are in this page, and how many of the 58 sources were queried and finished. |
| `sourceStatus[]` | One entry per source that mattered: ok or not, how many matched, whether the answer came from the continuously updated store. |
| `records[]` | The people, best match first. `matchConfidence` is a 0–1 score; `matchBasis[]` says which fields agreed; `matchState` is the date-of-birth verdict (`dob_match` here). |
| `records[].sources[]` | Which registry holds the record, its name, a link to the record there, and when it was last checked. |

> **The two fields that matter most.** Read `status` and `matchState` before anything else. `status` tells you whether the answer is complete; `matchState` tells you whether the person is verified by date of birth or only by name. A screening decision made on a `partial` answer or a name-only match is the single most common integration mistake. The [matching guide](/docs/matching) explains the states.

## GET, POST and DELETE: the same resource, three verbs

A RESTful API reuses the same URL shapes for every verb. The `POST` above created a search; a `GET` on `/v1/searches/{searchId}` reads it back without re-running it (and without a second charge on the live host); a `DELETE` on a monitor stops it. Note what changes and what does not: the host, the header and the version prefix are identical, only the method and the path differ.

```bash
# A GET: read a search you already created (no body, the id is in the path)
curl https://sandbox.offendersearch.app/v1/searches/srch_7c1e9a2b4f5d6e8091a2 \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"

# A DELETE: stop a monitor (an empty body answers 204)
curl -X DELETE https://sandbox.offendersearch.app/v1/monitors/mon_4d5e6f70819a2b3c4d5e \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

| Verb | Safe? | Idempotent? | Body? | Typical status |
| --- | --- | --- | --- | --- |
| `GET` | yes — reads only | yes | no | `200` |
| `POST` | no — creates (and on the live host, bills) | no — two POSTs are two searches | JSON | `200` with the answer, or `202` + an id when the work is asynchronous |
| `DELETE` | no | yes — deleting twice is still deleted | no | `204` |
| `PATCH`/`PUT` | no | PUT yes, PATCH usually | JSON | `200` |

Because `POST` is not idempotent, a client that retries a dropped `POST` must first check whether the first one succeeded. The API gives you the tool for that: persist the `searchId` as soon as any acknowledgement arrives, then recover by `GET`. The [async jobs guide](/docs/platform/async-and-webhooks) walks through it; [webhooks vs API polling](/docs/learn/webhooks-vs-api) covers when to be told instead of asking.

## API errors and status codes in a RESTful API

REST uses the HTTP status code as the first word of every error and a JSON body as the sentence. Here is the body you get when the request above forgets `lastName`:

```http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "detail": [
    { "loc": ["body", "query", "lastName"], "msg": "field required", "type": "value_error.missing" }
  ]
}
```

| Status | Meaning on this API | Retry? |
| --- | --- | --- |
| `401` | The key is missing, malformed, or a test key was sent to the live host (or the reverse). | No — fix the key or the host. |
| `422` | The body was refused: `detail[]` lists each bad field with `loc`, `msg` and `type`. A refused query (too broad) returns `detail` as an object with a `code`. | No — fix the request. |
| `429` | Rate limited; nothing was searched. `Retry-After` says how many seconds to wait (1–120); `detail.retryAfterSeconds` repeats it. | Yes — after `Retry-After`. |
| `5xx` | The server failed; nothing was billed. | Yes — with backoff. |

Branch on the status and on `detail.code`, never on the English sentence, which can be reworded. The full catalogue, including the criminal-records error object, is in [API errors](/docs/learn/api-errors) and the [errors reference](/docs/errors).

## Create a RESTful API client in 30 lines

A **RESTful API client** is a thin object that owns three things: the base URL, the headers, and the rule for errors. Everything else is one method per endpoint. Here is a complete one in Python and in JavaScript, with the one piece of resilience every client needs — honouring `Retry-After` on a `429`.

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

class OffenderSearch:
    """A minimal RESTful API client: one base URL, one header, one error type, retries on 429."""

    def __init__(self, key: str, base: str = "https://sandbox.offendersearch.app"):
        self.base, self.s = base, requests.Session()
        self.s.headers.update({"X-API-Key": key, "Content-Type": "application/json"})

    def _call(self, method: str, path: str, **kw):
        for attempt in range(4):
            r = self.s.request(method, f"{self.base}{path}", timeout=60, **kw)
            if r.status_code == 429:                        # rate limited: wait what the server says
                time.sleep(int(r.headers.get("Retry-After", "5")))
                continue
            if r.status_code >= 400:
                raise RuntimeError(f"{method} {path} -> {r.status_code}: {r.text}")
            return r.json() if r.content else None
        raise RuntimeError("gave up after four 429s")

    def search(self, first: str, last: str, dob: str | None = None, state: str | None = None):
        q = {"firstName": first, "lastName": last}
        if dob: q["dob"] = dob
        if state: q["state"] = state
        return self._call("POST", "/v1/search", json={"query": q})

    def get_search(self, search_id: str):
        return self._call("GET", f"/v1/searches/{search_id}")

client = OffenderSearch(os.environ["OFFENDERSEARCH_TEST_KEY"])
print(client.search("Exact", "Match", dob="1980-01-01")["counts"]["records"])   # 1
```

**Node**
```javascript
export class OffenderSearch {
  constructor(key, base = "https://sandbox.offendersearch.app") {
    this.base = base;
    this.headers = { "X-API-Key": key, "Content-Type": "application/json" };
  }
  async call(method, path, body) {
    for (let attempt = 0; attempt < 4; attempt++) {
      const res = await fetch(this.base + path, { method, headers: this.headers, body: body && JSON.stringify(body) });
      if (res.status === 429) {                                  // rate limited: wait what the server says
        await new Promise((r) => setTimeout(r, 1000 * Number(res.headers.get("Retry-After") ?? "5")));
        continue;
      }
      if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${await res.text()}`);
      return res.status === 204 ? null : res.json();
    }
    throw new Error("gave up after four 429s");
  }
  search(query) { return this.call("POST", "/v1/search", { query }); }
  getSearch(id) { return this.call("GET", `/v1/searches/${id}`); }
}

const client = new OffenderSearch(process.env.OFFENDERSEARCH_TEST_KEY);
console.log((await client.search({ firstName: "Exact", lastName: "Match", dob: "1980-01-01" })).counts.records); // 1
```

If you would rather not maintain this, the official packages do the same with typed responses: `pip install offendersearch` and `npm i @offendersearch/sdk` — see [SDKs](/sdk). Both are generated from the same [OpenAPI document](/openapi.json) the docs are built from, so they cannot drift from what the server does.

> **Keep the key out of the browser.** A RESTful API client belongs on your server. A key shipped in front-end JavaScript is public the moment the page loads. If your app is a browser app, call your own backend and let it call the API — the [Node tutorial](/background-check-api-nodejs) shows the pattern.

## RESTful API design conventions this example follows

- **Versioned paths** (`/v1`), so breaking changes are a new prefix, not a surprise.
- **JSON in, JSON out**, with `Content-Type: application/json` on every request that has a body.
- **Ids are opaque strings with a prefix** (`srch_`, `rec_`, `mon_`), so a log line tells you what kind of thing an id is without a lookup.
- **Sensitive input travels in the body**, never in the URL.
- **One error shape**: a status code plus a JSON `detail` you can branch on.
- **`Retry-After` on every `429`**, so a client never has to guess a backoff.
- **Async is explicit**: when work takes longer than a request, the API answers `202` with an id and a path to poll, rather than holding the connection open. Covered in [webhooks vs API polling](/docs/learn/webhooks-vs-api).
- **A sandbox with the production shape**, so the test you wrote is the integration you ship — see [public API for testing](/sandbox).

These are the same conventions you will meet on any well-run REST API. Learning them on one API with a free sandbox is faster than reading them in the abstract; the [what is an API glossary](/docs/learn/what-is-an-api) defines each term.

## A second RESTful API example: criminal records on a sibling endpoint

Consistency is the payoff of REST. The [criminal records API](/criminal-records-api) lives at `POST /v1/criminal/search` with the same header, the same `query` object and the same envelope idea; only the record shape differs (court cases, jail bookings, warrants, custody). The sandbox person **Felony Record**, born **1975-06-15**, returns one court case:

```bash
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}}'
```

The record comes back as `recordType: "court_case"` with `severity: "felony"`, `disposition: "Guilty"` and `matchState: "dob_match"`. Swap the name for **Arrest Only** (born 1992-04-20) and you get a `jail_booking` with no disposition — a booking is not a conviction, and the API says so in the data rather than in prose. Every fixed criminal test name is listed on the [criminal sandbox page](/docs/criminal/sandbox).

## What this example does not cover

- **OAuth and user login.** This API authenticates a server with an API key; there is no end-user authorization flow. If you need to know when OAuth is the right tool instead, read [API key vs OAuth](/docs/learn/api-key-vs-oauth).
- **GraphQL or gRPC.** Different styles with different trade-offs; the REST conventions here do not transfer one to one.
- **Pagination of huge collections.** A search returns the matches for one person; the long-list cases (alerts, FCRA events) use cursor paging documented on their own pages.
- **Bulk uploads.** A CSV of thousands of names goes through the [batch endpoint](/docs/batch), which is asynchronous by design.
- **What the data does not include.** The API returns sex offender registry and criminal public records. It does not run credit checks, driving records, identity verification or international searches, and it never asks for a Social Security number.

## FAQ: restful api example questions, answered

### What is an example of a RESTful API?

Any HTTP API that exposes resources at URLs and uses the HTTP methods for actions: `POST /v1/search` to create a search, `GET /v1/searches/{id}` to read it, `DELETE /v1/monitors/{id}` to remove a monitor. The request and response above are a complete, runnable example.

### What does a REST API request look like?

A method, a URL, headers (`X-API-Key`, `Content-Type: application/json`) and, for `POST`, a JSON body. The cURL tab in [the request example](#api-request-example) is the literal bytes on the wire.

### What is an API endpoint URL example?

`https://sandbox.offendersearch.app/v1/search` — scheme, host, version prefix, resource path. The method (`POST`) is part of the endpoint even though it is not part of the URL.

### What is a JSON API?

An API whose request bodies and responses are JSON documents, declared with `Content-Type: application/json`. Nearly every REST API today is a JSON API; the term also names a specific specification (jsonapi.org) that this API does not use.

### How do I test a REST API example without paying?

Use a sandbox. Here, a free account gives you test keys that work on `sandbox.offendersearch.app` with fixed test names and the production response shape, never billed. The live host also includes 25 free calls on sign-up with no card. Details on [public API for testing](/sandbox).

### How do I create a RESTful API client?

Wrap the base URL, headers and error rule in one object and add a method per endpoint, as in [the 30-line client](#restful-api-client). Or install the generated [SDK](/sdk) and skip it.

## Related

- [Learn APIs by example (hub)](https://offendersearch.app/docs/learn): every guide in this series
- [What is an API? Glossary](https://offendersearch.app/docs/learn/what-is-an-api): every term used above, defined
- [API token example](https://offendersearch.app/docs/learn/api-token-example): getting and handling the key
- [API errors](https://offendersearch.app/docs/learn/api-errors): the full error catalogue
- [Webhooks vs API polling](https://offendersearch.app/docs/learn/webhooks-vs-api): when to be told instead of asking
- [Public API for testing](https://offendersearch.app/sandbox): the free sandbox this page runs on
- [Background check API](https://offendersearch.app/background-check-api): the product behind the example
- [Sex offender API](https://offendersearch.app/sex-offender-api): 58 US registries in one call
- [Criminal records API](https://offendersearch.app/criminal-records-api): the sibling endpoint
- [Quickstart](https://offendersearch.app/docs/quickstart): the product quickstart
- [Authentication](https://offendersearch.app/docs/authentication): keys, hosts and security
- [Top 5 background check APIs compared](https://offendersearch.app/blog/best-background-check-api): how this API compares
