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.
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: 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 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 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.
API request example in cURL, Python and Node
Three identical requests. The header X-API-Key authenticates (see 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 -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"}}'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 below). The exact JSON schema is published in the OpenAPI document, which is also what the Postman collection and the SDKs 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.
{
"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. |
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 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.
# 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 walks through it; webhooks vs API polling 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/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 and the errors reference.
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.
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"]) # 1If 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. Both are generated from the same OpenAPI document the docs are built from, so they cannot drift from what the server does.
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/jsonon 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
detailyou 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
202with an id and a path to poll, rather than holding the connection open. Covered in webhooks vs API polling. - A sandbox with the production shape, so the test you wrote is the integration you ship — see public API for testing.
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 defines each term.
A second RESTful API example: criminal records on a sibling endpoint
Consistency is the payoff of REST. The 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:
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.
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.
- 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, 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 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.
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. Or install the generated SDK and skip it.