Crime data API
Crime incidents for 41 US and Canadian cities in a single, uniform payload — pick a city code, pick a date range, page through the results — plus a crime score for any address or coordinate and 311 service requests for the 27 cities that publish them. Every city returns the same field names with the same meanings, so you integrate once.
Overview
| Base URL | https://www.seecrime.com/api/v1 |
|---|---|
| Protocol | HTTPS only. Requests over plain HTTP are redirected and should be treated as a leaked credential. |
| Auth | API key in a request header. Server-to-server only. |
| Methods | GET only — the API is strictly read-only. |
| Format | JSON (UTF-8). gzip supported via Accept-Encoding. |
| Contract version | 1.4.1 — semver over the response contract, returned on every endpoint as contract_version. The MAJOR always matches the path version: v1 ↔ 1.4.1. A minor bump is additive — new endpoint or new field, nothing you built against changed shape. |
| Payload revision | 2026-09-03 — the date that contract version shipped. Cite it if a field looks different from what you built against; a support thread usually starts from “it worked last Tuesday”. Latest (2026-08-26, additive): permitted_use on /risk-score, request_id on every error body, and a distinct org_rate_limit_exceeded code. |
| Versioning | The version is in the path. A breaking change ships as /api/v2; v1 keeps its contract. New fields and new cities can appear in v1 at any time — treat unknown fields as additive and do not pin to field order. One narrow exception, and we would rather state it than let you discover it: if a field is found to be reporting a value it never measured, we correct it in place rather than carrying the wrong value forward in v1 until a v2 exists. That has happened once — on 2026-08-10 an unscored /risk-score response stopped zero-filling incidents.* and measures.*, which had included reporting trend: "stable" for a point we could not score at all. Those are null now. Payload revision above is how you detect such a change: it moves, and this note says what moved. We will not use this to reshape working fields. |
Authentication
Getting a key: API access is part of an Enterprise plan. Create an account or talk to us, and an owner or admin of your organization can then mint keys from this page.
Send your key in a header. Both forms are accepted:
# preferred Authorization: Bearer sck_xxxxxxxxxxxx_yyyyyyyy… # equivalent X-API-Key: sck_xxxxxxxxxxxx_yyyyyyyy…
The key is never accepted in the query string. A ?api_key= parameter would be copied verbatim into access logs, browser history, and every proxy in between — which is how API keys leak in practice. Requests without a header credential get 401 missing_api_key.
- A key looks like sck_<key_id>_<secret>. The key_id is public and identifies the key in your dashboard and in our audit log; the secret half is shown once, at creation, and we store only a hash of it. We cannot recover it for you — if it is lost, revoke and create a new one.
- Keys belong to your organization, not to an individual, so they survive staff changes and stay revocable by any org owner or admin.
- Do not use this API from browser JavaScript. We deliberately send no CORS headers: any key embedded in a web page is a public key. Call it from your backend.
- Revocation is immediate — it is checked on every request, not cached.
Quick start
curl -s "https://www.seecrime.com/api/v1/incidents?city=CHI&start=2026-07-01&end=2026-07-31&limit=5" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
Response:
{
"api_version": "v1",
"meta": {
"city_key": "CHI",
"city_name": "Chicago",
"city_country": "US",
"start": "2026-07-01",
"end": "2026-07-31",
"date_basis": "occurred",
"timezone": "America/Chicago",
"limit": 5,
"returned": 5,
"has_more": true,
"next_cursor": "eyJkIjoiMjAyNi0wNy0zMVQxOTowMDowMCIsImkiOiIxMzMzNzExNiJ9",
"multiple_records_per_incident": false,
"data_earliest": "2021-01-01",
"data_latest": "2026-07-30"
},
"incidents": [
{
"city_key": "CHI",
"city_name": "Chicago",
"city_state": "IL",
"city_timezone": "America/Chicago",
"record_id": "CHI-13337116",
"incident_code": "JH123456",
"incident_date": "2026-07-12T21:00:00",
"incident_date_basis": "occurred",
"incident_time_known": true,
"incident_offense": "Motor Vehicle Theft",
"incident_offense_code": "240",
"incident_offense_description": "Motor Vehicle Theft",
"incident_offense_detail_description": "Motor Vehicle Theft at 050XX N LAKE SHORE DR SB",
"incident_offense_crime_against": "Property",
"incident_offense_action": null,
"incident_offense_severity": 5,
"incident_offense_is_violent": false,
"incident_source_name": "Chicago_Police_Department",
"incident_source_original_type": "MOTOR VEHICLE THEFT - AUTOMOBILE",
"incident_source_original_code": "0910",
"incident_latitude": 41.975178,
"incident_longitude": -87.649961,
"incident_address": "050XX N LAKE SHORE DR SB",
"incident_arrest": false,
"incident_domestic": false,
"incident_source_fields": {
"description": "AUTOMOBILE",
"location_description": "STREET",
"beat": "2033",
"district": "020",
"ward": "48",
"community_area": "3"
}
}
]
}
GET /incidents
One page of incidents for one city. city is the only required parameter.
| Parameter | Type | Default | Notes |
|---|---|---|---|
city required | string | — | City code, e.g. CHI. Case-insensitive. |
start | date | end − 30d | YYYY-MM-DD, inclusive. |
end | date | today (UTC) | YYYY-MM-DD, inclusive of the whole day. |
offense | string list | all | Comma-separated canonical offenses, e.g. Robbery,Burglary. Case-sensitive — copy the value from the catalog verbatim; robbery returns 400 unknown_offense. |
crime_against | string list | all | Persons, Property, Society, Other. Case-insensitive, unlike offense. |
violent | boolean | — | true restricts to violent offenses, false excludes them. |
exclude_non_crime | boolean | false | Drop administrative records — see caveats. |
bbox | string | — | minLon,minLat,maxLon,maxLat (GeoJSON order). |
lat, lon, radius_km | numbers | — | Radius search; all three required together. Max radius 50 km. |
incident_code | string | — | Exact match on the agency's incident/case number. Up to 128 characters. Returns 400 unsupported_filter for a city that publishes no case number. |
limit | integer | 100 | 1–1000. Values above 1000 are clamped, not rejected. |
cursor | string | — | Opaque. Pass meta.next_cursor verbatim. |
Filters combine with AND, and offense, crime_against and violent intersect with each other. A combination with no possible matches returns an empty incidents array — not an error.
GET /risk-score
A crime score for a single point — pass coordinates, or a US street address and we geocode it. Same scorer that powers the risk card in the app, so the two never disagree.
Address lookup is US-only. The geocoder behind address= covers US street addresses; for a covered city outside the US, pass lat and lon directly — the scorer itself works anywhere we hold data. An address we cannot locate returns 422 address_not_found, which is deliberately distinct from 503 geocoder_unavailable.
| Parameter | Type | Notes |
|---|---|---|
lat + lon | numbers | WGS-84. lng is accepted as an alias for lon. Fastest path — no geocoding. |
address | string | Up to 200 characters. A full street address with city and state matches best. Ignored when lat/lon are supplied. |
Supply either coordinates or an address. The search radius is a fixed ¼ mile (0.4 km) and is not configurable — two addresses are only comparable when the circle is the same size.
curl -s "https://www.seecrime.com/api/v1/risk-score?address=233+S+Wacker+Dr,+Chicago,+IL" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
{
"api_version": "v1",
"query": {
"lat": 41.878876, "lon": -87.635915,
"address": "233 S Wacker Dr, Chicago, IL",
"matched_address": "233 S WACKER DR, CHICAGO, IL, 60606",
"geocoder": "census",
"radius_km": 0.4
},
"city_key": "CHI", "city_name": "Chicago", "city_state": "IL",
"scored": true,
"score": 54, "grade": "C", "label": "Moderate Risk",
"incidents": {
"radius_km": 0.4, "total": 128, "violent": 31, "property": 74,
"last_30_days": 39, "last_90_days": 128,
"top_offenses": [{"offense": "Larceny/Theft", "count": 58}]
},
"measures": {
"vs_city_average_pct": 18.4, "trend": "stable",
"night_share_pct": 34, "night_share_coverage": 1.0, "has_night_data": true,
"arrest_rate_pct": 11, "has_arrest_data": true,
"has_foot_traffic_data": true
},
"coverage": {
"limitation": null, "area_coverage_pct": 1.0,
"nearby_incidents": 4412, "nearby_radius_km": 5.0,
"city_has_recent_data": true, "outside_data_footprint": false
}
}
Read scored before you read score
score and grade are null whenever scored is false — never 0, and never a grade. A zero would be a number you could average, chart or compare to a threshold, and it would read as safest possible when the truth is we don't know. A point we cannot score still returns HTTP 200: the request was fine, the answer is "no data here".
It is not only score. When scored is false, every measured field is null and top_offenses is [] — nothing under incidents or measures carries information, including trend, which would otherwise have read "stable" for a point in the middle of the ocean. What stays meaningful is coverage (facts about what we know — a nearby_incidents of 0 is the evidence for the verdict, not a fabricated count), label, query.radius_km, the has_*_data capability booleans, and the city_* fields. Branch on scored before you read anything else.
Response fields
| Field | Type | Description |
|---|---|---|
scored | boolean | Whether there was enough data to produce a verdict. Read this first. |
score / grade | integer | null string | null | 0–100 and a letter. null when scored is false. |
label | string | The plain-language verdict that matches the grade — e.g. Very Low Risk, Moderate Risk. Set to No Coverage on an unscored point, so it is meaningful either way. Treat it as display text, not as an enum to branch on: use grade or coverage.limitation for logic. |
incidents.radius_km | number | The scored circle — an echo of the fixed 0.4 km, not a measurement, so it survives an unscored response. |
incidents.total | integer | null | Incidents in the circle over the trailing 90 days. Incidents, not offense rows — de-duplicated for the cities that file one row per offense, so this agrees with what /incidents returns for the same area. |
incidents.violent / .property | integer | null | A partition of total, and they sum to it. Violent follows the FBI definition, so robbery counts as violent — see incident_offense_is_violent. property is therefore everything not violent, which is wider than the NIBRS Property group. |
incidents.last_30_days / .last_90_days | integer | null | The same count over two windows. The 90-day figure is the whole scored window, so it equals total. |
incidents.top_offenses | array | [{"offense", "count"}], most common first. [] when unscored. |
measures.vs_city_average_pct | number | null | Percentage above (positive) or below (negative) this city's own density baseline, to one decimal. Comparable within a city, not across cities. |
measures.trend | string | null | Exactly one of rising, falling, stable — recent volume against the earlier part of the window. This is the machine value; do not parse a direction out of any display string. |
measures.night_share_pct | integer | null | Percentage of the nearby incidents that occurred overnight (22:00–04:59 local). Measured over the incidents whose hour we could read, not over all of them — which is what the next field tells you. null where no nearby record carried a readable hour. |
measures.night_share_coverage | number | null | Read this alongside night_share_pct. The fraction of the scored incidents the percentage was actually measured over — 1.0 means every one carried a timestamp, 0.08 means eight per cent did. Without it, a feed that timestamps a twelfth of its records produces a night share that looks exactly like a complete one. |
measures.has_night_data | boolean | false when there is no time-of-day signal at all. Display a dash, not a zero. |
measures.arrest_rate_pct | integer | null | Percentage of the nearby incidents carrying an arrest disposition. Only meaningful where has_arrest_data is true; elsewhere treat it as absent, not as a low clearance rate. |
measures.has_arrest_data | boolean | Whether this city publishes a disposition we are willing to count. true for 4 cities. This is a wider set than incident_arrest's and the two are not interchangeable: a per-record boolean needs a column that means "an arrest was made on this record", while a rate only needs a disposition we can count. Do not infer one from the other. |
measures.has_foot_traffic_data | boolean | This one affects score itself. The score includes a foot-traffic normalisation worth up to 15 points, derived from open POI and census-population data for the surrounding quarter mile. Those are third-party services we query under a strict outbound budget, so occasionally we cannot get a reading — and when we cannot, we skip the adjustment rather than substitute an average. false therefore means this score omits an input that other scores include, and the difference has been measured at a full letter grade. If you are ranking or thresholding addresses, either filter these out or re-request them later; do not treat a false here as "average foot traffic". We do not publish the index itself — it is an internal heuristic you could not recompute. |
coverage.limitation | string | null | The one field to branch on. See the table below. |
coverage.area_coverage_pct | number | Geometric, and explicitly NOT evidence of data coverage. The fraction of the search circle that falls inside the city we matched, so an address on a boundary is not divided by a full circle's worth of expected incidents. A point can sit in the middle of a city — 1.0 here — with no data published anywhere near it. nearby_incidents is what answers that, by counting rows. |
coverage.nearby_incidents | integer | How many incidents exist within the wider nearby_radius_km ring. This is the measurement that separates "a quiet block" from "our data does not reach here". |
coverage.nearby_radius_km | number | The ring that count was taken over — 5 km. |
coverage.city_has_recent_data | boolean | false when we cover this city but its feed has stopped publishing recent records. |
coverage.outside_data_footprint | boolean | true when nothing at all was published within the ring. |
query | object | What we resolved your request to: lat, lon, radius_km, and for an address lookup address, matched_address and geocoder. Log matched_address — it is how you tell a good score for the wrong building from a good score for the right one. |
coverage.limitation
One field to branch on. null means score it as read.
| Value | scored | Meaning |
|---|---|---|
outside_covered_cities | false | The point is not in any city we cover. Check /cities. |
city_feed_not_current | false | We cover this city but its source feed has stopped publishing recent data. Not a low-crime finding. |
outside_data_footprint | false | Inside the city's boundary, but nothing published within 5 km. The city's data does not reach here. |
partial_area_coverage | true | Scored, but under half the search circle lies inside the city we matched — an edge or boundary address. Treat as indicative. |
Zero incidents does not by itself mean no coverage. A genuinely quiet residential block also returns zero, so we additionally count what exists within nearby_radius_km: something nearby and nothing in the circle means quiet, and it still gets a grade. Nothing nearby either means the data does not reach the address, and that is the case we refuse to grade.
Errors specific to this endpoint
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameter | Neither an address nor a complete coordinate pair, or a coordinate out of range. |
| 422 | address_not_found | We asked the geocoder and it does not know that address. Fix the address, or send coordinates. |
| 503 | geocoder_unavailable | We could not reach the geocoder. Your address may be perfectly fine — retry, or send coordinates. Deliberately distinct from address_not_found. |
| 500 | score_failed | The score could not be computed. Nothing is wrong with your request — retry shortly. Distinct from an unscored 200, which is a real answer meaning "we hold no data here". |
Address lookups add roughly 0.2–2 s on a first request and are cached afterwards. If you are scoring the same locations repeatedly, geocode once on your side and pass lat/lon.
The score is calibrated for comparison between addresses, using severity-weighted incident density against the city's own baseline. It is not a prediction, and it is not a statement about any person or property. Do not use it for tenant screening, lending, insurance underwriting, or any other decision about an individual — see the terms.
GET /service-requests
311 service requests — potholes, graffiti, illegal dumping, missed collections — for the 27 of 41 cities that publish them. Same city codes, same pagination, same date semantics as /incidents.
request_type is NOT normalised across cities, and that is the one thing to understand before using this endpoint. Crime has a real cross-city taxonomy behind incident_offense — 311 has no equivalent, so a "Pothole" in one city may be "Street Repair – Pavement" in the next. The field names are uniform; the values are each city's own vocabulary, and meta.request_type_vocabulary says "city_specific" on every response to keep that in front of you. Use /service-request-types to enumerate a city's vocabulary rather than guessing strings. Do not group or sum request_type across cities.
| Parameter | Type | Notes |
|---|---|---|
city required | string | City code. Must be one with 311 coverage. |
start / end | date | YYYY-MM-DD. Defaults to the last 30 days, on the request's opened date. |
type | string | Exact match on that city's own request_type. Up to 200 characters. Enumerate the vocabulary with /service-request-types. |
status | string | Exact match. Up to 100 characters. Returns 400 unsupported_filter for the 2 cities that publish no status field at all — meta.publishes_status tells you which case you are in before you filter. |
open_only | boolean | Only requests with no close date. Returns 400 unsupported_filter for the 5 cities that publish no close date, rather than silently returning everything — check meta.publishes_closed_date first. |
request_code | string | Exact match on the city's service-request number. Up to 128 characters. |
bbox, lat/lon/radius_km | — | Identical to /incidents. Max radius 50 km. |
limit, cursor | — | Identical to /incidents. Max 1000. |
curl -s "https://www.seecrime.com/api/v1/service-requests?city=CHI&type=Pothole+in+Street&limit=2" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
{
"meta": {
"city_key": "CHI", "start": "2026-07-11", "end": "2026-08-10",
"limit": 2, "returned": 2, "has_more": true, "next_cursor": "eyJk…",
"request_type_vocabulary": "city_specific",
"publishes_closed_date": true,
"publishes_status": true
},
"service_requests": [
{
"city_key": "CHI", "city_name": "Chicago", "city_state": "IL",
"city_timezone": "America/Chicago",
"record_id": "CHI-SR26-01234567",
"request_code": "SR26-01234567",
"request_type": "Pothole in Street",
"request_subtype": "PHF",
"request_status": "Completed",
"request_is_open": false,
"request_opened_date": "2026-07-28T08:14:00",
"request_opened_time_known": true,
"request_closed_date": "2026-08-04T15:02:00",
"request_channel": "Phone Call",
"request_latitude": 41.902117, "request_longitude": -87.663443,
"request_address": "1600 N WESTERN AVE",
"request_source_fields": {
"zip_code": "60647", "ward": "1", "community_area": "24",
"community_area_name": "WEST TOWN", "police_district": "014"
}
}
]
}
request_is_open is tri-state
5 of the 27 cities publish no close date at all. For those, request_is_open is null — not true, because "no close date recorded" and "still open" are different claims and we only know the first. meta.publishes_closed_date tells you which case you are in, and open_only returns 400 unsupported_filter rather than silently returning everything.
Field notes
| Field | Description |
|---|---|
record_id | Globally unique (<CITY>-<service request number>). Your primary key. |
request_opened_date | ISO-8601 local civil time, no offset — same contract as incident_date. Combine with city_timezone. |
request_channel | How it was reported (phone / web / app). Meaning is uniform; the value vocabulary is city-specific. |
request_source_fields | City-specific, not comparable. Dallas and Austin put a department in community_area_name; Phoenix puts a patrol grid there. Read a city's own portal for meaning. |
Asking for a city we cover for crime but that publishes no 311 returns 404 no_311_coverage — deliberately different from unknown_city, which means the code itself was wrong.
GET /service-request-types
Every distinct request_type for one city, with counts, most common first. This is how the type filter becomes usable — call it once per city and cache it.
curl -s "https://www.seecrime.com/api/v1/service-request-types?city=CHI" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
{
"api_version": "v1",
"city_key": "CHI", "city_name": "Chicago",
"count": 96,
"truncated_at": null,
"request_types": [
{ "request_type": "Pothole in Street", "count": 184203 },
{ "request_type": "Graffiti Removal", "count": 151874 }
]
}
Capped at 500 types; truncated_at is null unless a city exceeded it, in which case it holds the cap. Asking for a city we cover for crime but that publishes no 311 returns 404 no_311_coverage, the same as /service-requests — a code that is not recognised at all is 400 unknown_city.
GET /cities
Every covered city with its code, timezone, publishing agency, available date range, and per-city capabilities. Call this at startup and cache it — it is how a newly added city reaches your integration without a code change on your side. No parameters.
curl -s "https://www.seecrime.com/api/v1/cities" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
{
"api_version": "v1",
"count": 41,
"cities": [
{
"city_key": "CHI",
"city_name": "Chicago",
"city_state": "IL",
"city_country": "US",
"city_timezone": "America/Chicago",
"source_name": "Chicago_Police_Department",
"date_basis": "occurred",
"data_earliest": "2021-01-01",
"data_latest": "2026-07-30",
"multiple_records_per_incident": false,
"supports": {
"date_filter": true,
"offense_filter": true,
"spatial_filter": true,
"arrest_field": true,
"domestic_field": true
},
"has_311_data": true
}
]
}
| Field | Type | Description |
|---|---|---|
count | integer | Number of entries in cities. It is the whole roster — this endpoint does not paginate. |
city_key | string | The code you pass as city everywhere else. Stable forever: a published code is never reused or re-pointed at a different city, because it is your primary key. |
city_name / city_state / city_timezone | string | Display name, state/province code (USPS for a US city, Canada Post for a Canadian one), and the IANA zone that this city's incident_date values are expressed in. |
city_country | string | ISO 3166-1 alpha-2 country code (US or CA). city_state is the state/province code within it. |
source_name | string | The publishing agency, underscored — who says so. Matches incident_source_name on every record from this city. |
date_basis | string | occurred or reported. Which event the city's feed is keyed on, and therefore what start/end filter against. See caveats. |
data_earliest / data_latest | string | null | YYYY-MM-DD spanning everything we hold for that city — not the window of any query you have run. data_latest reflects that agency's own publication lag, so it can sit days or weeks behind today while nothing at all is wrong; it is the authoritative answer to "how current is this city?". null when we currently hold nothing. |
multiple_records_per_incident | boolean | true where the agency files one row per offense, so several records can share an incident_code. Count record_id for offenses, DISTINCT incident_code for incidents. 21 of 41 cities are true. |
has_311_data | boolean | Whether you can call /service-requests and /service-request-types for this city. 27 of 41 are true; the rest return 404 no_311_coverage. Branch on this rather than on a list of your own. |
supports.date_filter | boolean | Whether start/end are honoured. true everywhere today. |
supports.offense_filter | boolean | Whether offense, crime_against and violent are honoured. true everywhere today. |
supports.spatial_filter | boolean | Whether bbox and the lat/lon/radius_km trio are honoured. true everywhere today. |
supports.arrest_field | boolean | Whether incident_arrest carries a real value for this city. True for 1 of 41 — check it before building anything that depends on arrests. |
supports.domestic_field | boolean | Whether incident_domestic carries a real value. True for 2 of 41. |
GET /offenses
The complete offense vocabulary — every value incident_offense can take, with its NIBRS code, group, severity weight and violent flag. Static apart from taxonomy releases, and the same 25 values rendered in the catalog below. No parameters.
curl -s "https://www.seecrime.com/api/v1/offenses" \ -H "Authorization: Bearer $SEECRIME_API_KEY"
{
"api_version": "v1",
"count": 25,
"offenses": [
{
"offense": "Robbery",
"offense_code": "120",
"crime_against": "Property",
"severity": 8,
"is_violent": true,
"is_crime": true
},
{
"offense": "Non-Crime",
"offense_code": null,
"crime_against": "Other",
"severity": 0,
"is_violent": false,
"is_crime": false
}
]
}
| Field | Type | Description |
|---|---|---|
offense | string | The value incident_offense takes, and the value you pass to the offense filter. Case-sensitive there — copy it verbatim. This, not offense_code, is the unique key. |
offense_code | string | null | NIBRS code for the representative offense in the roll-up. null for Non-Crime, which is not an offense. Not unique — see the note under the catalog. |
crime_against | string | NIBRS group: Persons, Property, Society, Other. What the crime_against filter matches. |
severity | integer | 0–10 weight, consistent across cities. See the note under the catalog. |
is_violent | boolean | FBI violent-crime definition — the same flag as incident_offense_is_violent, and what violent=true selects. Robbery is true despite sitting in the NIBRS Property group. |
is_crime | boolean | false only for Non-Crime, the administrative-record bucket. exclude_non_crime=true on /incidents drops exactly these. |
Incident fields
Everything at the top level has the same meaning in every city. Anything whose meaning varies by agency is quarantined inside incident_source_fields.
| Field | Type | Description |
|---|---|---|
city_key | string | City code, e.g. CHI. |
city_name / city_state | string | Display name and state/province code (USPS for a US city, Canada Post for a Canadian one). |
city_timezone | string | IANA zone that incident_date is expressed in. |
record_id | string | Globally unique per row (<CITY>-<source key>). Use this as your primary key and for idempotent upserts. |
incident_code | string | The agency's incident/case number. Not unique where an agency files one row per offense — see multiple_records_per_incident. |
incident_date | string | ISO-8601 local civil time, no offset — 2026-07-12T21:00:00, or 2026-07-12 when the feed publishes no time. Combine with city_timezone to get an absolute instant. |
incident_date_basis | string | occurred or reported. Read this before comparing cities. |
incident_time_known | boolean | false when the agency published a date only. Do not infer midnight. |
incident_offense | string | Canonical offense — the cross-city classification. This is the field to group and filter on. |
incident_offense_code | string | NIBRS-aligned code for the canonical offense — ours, derived identically for every city, so it stays cross-city (and cross-border) comparable. Not the agency's own code — that is incident_source_original_code. null for Non-Crime. |
incident_offense_description | string | Human-readable form of incident_offense. |
incident_offense_detail_description | string | Offense plus location, ready to display. |
incident_offense_crime_against | string | NIBRS group: Persons, Property, Society, Other. |
incident_offense_action | string | null | NIBRS attempted/completed. Tri-state: A where the agency's offense text says attempted, C where we can read a feed that would have said so, and null where this feed gives no readable signal either way. Only 6 of 41 cities can assert C; treating null as completed invents an attempted/completed split. |
incident_offense_severity | integer | 0–10 severity weight, consistent across cities. Ours, not NIBRS. 0 is reserved for Non-Crime — an administrative record carries no offense severity — and every real offense is 1–10. A client asserting 1 <= severity <= 10, or normalising by dividing, breaks on it. |
incident_offense_is_violent | boolean | FBI violent-crime definition. Note that robbery counts as violent even though NIBRS groups it under Property. |
incident_source_name | string | Publishing agency. |
incident_source_original_type | string | The agency's own offense string, verbatim — what our classification was derived from. In the source language (Montreal's is French, e.g. Vols qualifiés). |
incident_source_original_code | string | The agency's own offense code, verbatim. Vocabulary differs per city. For a Canadian city this is a Canadian UCR code, not FBI/NIBRS — not comparable to a US city's; see caveats. |
incident_latitude / incident_longitude | number | WGS-84, 6 decimal places, as published. Many agencies publish block-level or offset coordinates — see caveats. |
incident_address | string | Block or intersection as published. null where the agency publishes none. |
incident_arrest | boolean | null | null means the agency does not publish it, which is not the same as "no arrest". Populated for 1 of the 41 cities — everywhere else it is null on every record. Check supports.arrest_field on /cities. |
incident_domestic | boolean | null | Same tri-state rule, and populated for 2 of the 41 cities. Only where the source column genuinely means domestic/family violence. Check supports.domestic_field. |
incident_source_fields | object | City-specific, not comparable across cities. Verbatim agency values for description, location_description, beat, district, ward, community_area. What each holds depends on the feed — one city's district is a police district, another's is a ZIP. |
incident_arrest and incident_domestic are null on almost every record, and that is the answer, not a gap. An arrest disposition we are willing to stand behind is published by 1 of the 41 agencies; a domestic-violence flag by 2. Read supports.arrest_field and supports.domestic_field from /cities before you build a clearance-rate or domestic-violence feature — otherwise you will ship one that silently reports near-zero nationwide.
Several other feeds do carry something in the equivalent slot, but it is not the same thing: a shooting indicator in one city, a firearm-involved flag in another, a case-resolution string in a third. Publishing those as incident_domestic or incident_arrest would be wrong data under a right-sounding name, so they stay verbatim inside incident_source_fields where their meaning is documented as city-specific.
City codes
41 cities27 also publish 311
| Code | City | State | Country | Timezone | Date basis | 311 | Rows per incident | Source |
|---|---|---|---|---|---|---|---|---|
ATL |
Atlanta | GA | US | America/New_York | occurred | no | many | Atlanta Police Department |
AUS |
Austin | TX | US | America/Chicago | occurred | yes | one | Austin Police Department |
BAL |
Baltimore | MD | US | America/New_York | occurred | yes | many | Baltimore Police Department |
BOI |
Boise | ID | US | America/Boise | occurred | no | many | Boise Police Department |
BOS |
Boston | MA | US | America/New_York | occurred | yes | one | Boston Police Department |
BTR |
Baton Rouge | LA | US | America/Chicago | reported | yes | many | Baton Rouge Police Department |
CHI |
Chicago | IL | US | America/Chicago | occurred | yes | one | Chicago Police Department |
CLE |
Cleveland | OH | US | America/New_York | reported | yes | many | Cleveland Division of Police |
CLT |
Charlotte | NC | US | America/New_York | occurred | yes | one | Charlotte-Mecklenburg Police Department |
COS |
Colorado Springs | CO | US | America/Denver | reported | no | many | Colorado Springs Police Department |
DAL |
Dallas | TX | US | America/Chicago | occurred | yes | one | Dallas Police Department |
DC |
Washington DC | DC | US | America/New_York | occurred | yes | one | Metropolitan Police Department DC |
DEN |
Denver | CO | US | America/Denver | occurred | no | many | Denver Police Department |
DET |
Detroit | MI | US | America/Detroit | occurred | yes | many | Detroit Police Department |
EVN |
Evanston | IL | US | America/Chicago | occurred | no | one | Evanston Police Department |
HOU |
Houston | TX | US | America/Chicago | occurred | yes | one | Houston Police Department |
JAX |
Jacksonville | FL | US | America/New_York | reported | no | many | Jacksonville Sheriffs Office |
KCMO |
Kansas City | MO | US | America/Chicago | reported | yes | many | Kansas City Missouri Police Department |
LA |
Los Angeles | CA | US | America/Los_Angeles | occurred | yes | one | Los Angeles Police Department |
LV |
Las Vegas | NV | US | America/Los_Angeles | reported | no | many | Las Vegas Metropolitan Police Department |
MEM |
Memphis | TN | US | America/Chicago | occurred | yes | one | Memphis Police Department |
MESA |
Mesa | AZ | US | America/Phoenix | reported | no | one | Mesa Police Department |
MKE |
Milwaukee | WI | US | America/Chicago | occurred | no | one | Milwaukee Police Department |
MSP |
Minneapolis | MN | US | America/Chicago | occurred | yes | many | Minneapolis Police Department |
MTL |
Montreal | QC | CA | America/Toronto | reported | yes | one | Service de police de la Ville de Montreal SPVM |
NSH |
Nashville | TN | US | America/Chicago | occurred | yes | many | Metro Nashville Police Department |
NYC |
New York | NY | US | America/New_York | occurred | yes | one | New York City Police Department |
OAK |
Oakland | CA | US | America/Los_Angeles | occurred | yes | many | Oakland Police Department |
OMA |
Omaha | NE | US | America/Chicago | occurred | yes | many | Omaha Police Department |
ORL |
Orlando | FL | US | America/New_York | reported | no | one | Orlando Police Department |
OTT |
Ottawa | ON | CA | America/Toronto | reported | no | one | Ottawa Police Service |
PHL |
Philadelphia | PA | US | America/New_York | reported | yes | one | Philadelphia Police Department |
PHX |
Phoenix | AZ | US | America/Phoenix | occurred | yes | one | Phoenix Police Department |
RAL |
Raleigh | NC | US | America/New_York | reported | yes | many | Raleigh Police Department |
SAC |
Sacramento | CA | US | America/Los_Angeles | occurred | yes | one | Sacramento Police Department |
SAN |
San Diego | CA | US | America/Los_Angeles | occurred | yes | one | San Diego Police Department |
SEA |
Seattle | WA | US | America/Los_Angeles | occurred | yes | many | Seattle Police Department |
SF |
San Francisco | CA | US | America/Los_Angeles | occurred | yes | many | San Francisco Police Department |
TOR |
Toronto | ON | CA | America/Toronto | reported | no | many | Toronto Police Service |
TPA |
Tampa | FL | US | America/New_York | reported | no | many | Tampa Police Department |
TUS |
Tucson | AZ | US | America/Phoenix | reported | no | many | Tucson Police Department |
“Rows per incident: many” means the agency files one record per offense, so several rows can share an incident_code. Count record_id for offenses; group by incident_code for incidents.
“311: yes” means /service-requests and /service-request-types work for that code. The same fact is on every /cities entry as has_311_data — read it from there rather than copying this column.
Offense catalog
| Offense | NIBRS | Crime against | Severity | Violent | Is a crime |
|---|---|---|---|---|---|
Animal Cruelty |
720 | Society | 5 | no | yes |
Arson |
200 | Property | 8 | yes | yes |
Assault |
13A | Persons | 7 | yes | yes |
Burglary |
220 | Property | 6 | no | yes |
DUI |
90D | Society | 3 | no | yes |
Disorderly Conduct |
90C | Society | 2 | no | yes |
Drug/Narcotic |
35A | Society | 4 | no | yes |
Fraud/Forgery |
26A | Property | 3 | no | yes |
Gambling |
39A | Society | 1 | no | yes |
Homicide |
09A | Persons | 10 | yes | yes |
Kidnapping |
100 | Persons | 9 | yes | yes |
Larceny/Theft |
23H | Property | 4 | no | yes |
Liquor/Public Order |
90G | Society | 2 | no | yes |
Motor Vehicle Theft |
240 | Property | 5 | no | yes |
Non-Crime |
— | Other | 0 | no | no |
Offense Against Family |
90F | Persons | 6 | no | yes |
Other |
90Z | Other | 2 | no | yes |
Prostitution |
40A | Society | 2 | no | yes |
Protective Order Violation |
90Z | Persons | 6 | no | yes |
Robbery |
120 | Property | 8 | yes | yes |
Sex Offense |
11A | Persons | 9 | yes | yes |
Stolen Property |
280 | Property | 4 | no | yes |
Trespass |
90J | Property | 3 | no | yes |
Vandalism |
290 | Property | 3 | no | yes |
Weapons |
520 | Society | 7 | yes | yes |
Our offenses are deliberate roll-ups — Assault spans NIBRS 13A/13B/13C, Sex Offense spans the 11x/36x family — so the NIBRS code shown is the representative offense for the group, not a claim about every row. When you need the exact offense, read incident_source_original_type. Two offenses share 90Z because NIBRS has no dedicated code for a protective-order violation; incident_offense, not the code, is the unique key.
Severity runs 0–10, not 1–10. 0 is reserved for Non-Crime, because an administrative record is not an offense and carries no offense severity; every real offense is 1–10. So do not assert 1 <= severity <= 10, and do not normalise by dividing by a value that can be 0. Filter with exclude_non_crime=true, or check is_crime, before doing severity arithmetic.
Pagination
Cursor-based, newest first. Follow meta.next_cursor until meta.has_more is false:
cursor = None
while True:
q = {"city": "CHI", "start": "2026-01-01", "end": "2026-06-30", "limit": 1000}
if cursor: q["cursor"] = cursor
r = requests.get(BASE + "/incidents", params=q, headers=H, timeout=30).json()
yield from r["incidents"]
if not r["meta"]["has_more"]:
break
cursor = r["meta"]["next_cursor"]
- Page 10,000 costs the same as page 1. There is no
offsetand no page number; the cursor is a keyset position, so we never re-scan what you have already read. That is also why there is no maximum date range — walk a decade if you need it. - Treat the cursor as opaque. Its contents are an implementation detail and may change within v1.
- We do not return a total count. Counting exactly would mean a full scan per request;
has_moreanswers the question that actually drives a loop. - Cursors are stable against a fixed dataset. Ingest runs once a day, so a very long paging run that straddles it may see a row shift; restart the window if you need a perfectly consistent snapshot. See how current the data is.
Caching
- Every response carries an
ETag. Send it back asIf-None-Matchand an unchanged window answers304 Not Modifiedwith no body — the cheapest way to poll. Cache-Control: private, max-age=60on/incidentsand/service-requests;3600on/cities;86400on/offenses. The remaining endpoints set their own — read the header rather than assuming one of these.privateon every response, always: it is served against your credential, so it must never land in a shared cache.
How current the data is
Our ingest sweep starts at 09:00 UTC and works through the cities one at a time. That is a start time, not an instant: the sweep is bounded by a wall-clock budget and rotates where it left off, so a large city can land several hours later, or roll over to the following day's run. Polling more often than hourly will not surface anything new.
data_latest on /cities is the authoritative answer to "how current is this city?" — not the clock. On top of our schedule, every agency publishes on its own cadence and with its own lag; some are a day behind, some are weeks, and at least one feed is frozen upstream. An empty recent window is almost always publication lag rather than an absence of crime, and data_latest is what distinguishes the two. Read it per city instead of assuming a single freshness guarantee across the API.
Rate limits
Three limits apply to every request, on different axes. You need to know about all three, because the one that stops you is not always the one you sized for.
| Limit | Counted per | Notes |
|---|---|---|
| 120/minute (default) | API key | Configurable per key — ask us if you need more for a bulk load. Reported in the X-RateLimit-* headers below. |
| 600/minute | Organization | Every key you hold counts against this together. It is why minting more keys does not buy throughput — an organization may hold up to 20 active keys, and they share this ceiling. Exceeding it returns org_rate_limit_exceeded rather than rate_limit_exceeded, so you can tell the two apart. Remaining budget is reported in X-RateLimit-Org-Remaining. |
| 600/minute | Source IP address | A coarse backstop on every endpoint. Not per key — see the two consequences below. |
The per-key limit runs in a fixed 60-second window, and every response tells you where you stand against it:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1786000260 # unix seconds
Exceeding it returns 429 rate_limit_exceeded with Retry-After. Honour it — retry with exponential backoff rather than a tight loop.
Two consequences of the per-IP limit
- Keys behind one egress address share it. If several of your services call us through the same NAT gateway or egress IP, their requests count together against 600/minute regardless of how many separate keys they hold. Distinct keys give you distinct per-key budgets and separate audit trails; they do not give you distinct IP budgets.
- Raising one key above 600/minute achieves nothing on its own. The per-IP cap is checked first, so a key allowed more than 600/minute will still be refused at 600 from a single address. If you need to go beyond it, tell us what the traffic shape is — the answer is usually spreading the load across sources rather than one very large key.
A per-IP 429 carries no Retry-After and no X-RateLimit-* headers. It is produced before your credential is even read, so there is no key to report a budget for. The body is still {"error": "rate_limit_exceeded", …}, so branching on the code works either way — but a client that reads Retry-After unconditionally will find it missing. Treat a 429 with no X-RateLimit-Reset as "back off for the remainder of this minute", then resume with normal exponential backoff.
Errors
Every error is JSON with a stable error code and a human message. Branch on the code, not the message.
{ "error": "unknown_city", "message": "The \"city\" parameter is required and must be a city code. See GET /api/v1/cities for the current list." }
| Status | Code | Meaning |
|---|---|---|
| 400 | unknown_city | city missing or not a recognised code. |
| 400 | invalid_date / invalid_date_range | Malformed date, or start after end. |
| 400 | invalid_parameter | A value is the wrong type or out of range. |
| 400 | invalid_bbox | bbox does not have exactly four comma-separated values. Four values that are not valid numbers are invalid_parameter instead. |
| 400 | invalid_cursor | Cursor not recognised. Omit it to restart. |
| 400 | unknown_offense | Value outside the catalog. offense is case-sensitive — robbery is rejected, Robbery is not. Copy the value from /offenses verbatim. |
| 400 | unknown_crime_against | Not one of Persons, Property, Society, Other. Unlike offense, this one is case-insensitive. |
| 400 | unsupported_filter | The filter is real, but this city cannot answer it — status or open_only where the city publishes no status or no close date, or incident_code where it publishes no case number. Not a retry: drop that parameter for this city. meta.publishes_status / meta.publishes_closed_date and /cities let you know in advance. |
| 400 | city_unavailable | Also emitted as a 400, not only 503: a recognised city whose feed configuration is currently incomplete. If you branch on status alone you will mis-handle one of the two — branch on the code. |
| 401 | missing_api_key | No credential header. |
| 401 | invalid_api_key | Unknown, malformed or revoked key. Deliberately the same body for all three, so it cannot be used to probe which key IDs exist. |
| 401 | api_key_expired | Correct key, past its expiry. |
| 404 | no_311_coverage | A city we cover for crime that publishes no 311 data. Distinct from unknown_city on purpose: one means stop asking, the other means you mistyped the code. Check has_311_data on /cities. |
| 429 | rate_limit_exceeded | Slow down. See Rate limits — Retry-After is present on a per-key 429 and absent on a per-IP one. |
| 500 | query_failed | We could not complete it. Narrow the range and retry. |
| 500 | score_failed | /risk-score only. The score could not be computed; your request was fine. Retry shortly. |
| 503 | city_unavailable | That city is temporarily not queryable. |
| 503 | filter_unavailable | We could not resolve the offense / crime_against / violent filter for this city right now. The request was valid, and we return this rather than quietly dropping the filter and serving you unfiltered rows under a 200. Retry with backoff. |
| 503 | service_unavailable | We could not reach the credential store to check your key. This is not a problem with your key. Retry with backoff — do not revoke and re-issue. A client that treats every failure on the auth path as a bad credential will eventually revoke a perfectly good key over a momentary database blip. |
Data caveats
These are properties of the source data, not of the API. They are documented because getting them wrong produces analysis that looks fine and is wrong.
- Timestamps are local, with no offset. Agencies publish local civil time. We do not stamp a
Zon it — that would be wrong by five to eight hours on every row. Localise usingcity_timezone. 3 cities (Mesa, Phoenix, Tucson) do not observe DST — their IANA zone says so, which is why you should localise fromcity_timezonerather than from a UTC offset you worked out once. occurredvsreportedis not cosmetic. 15 of 41 cities key their feed on when the report was filed. Comparing those tooccurredcities day-by-day will mis-date incidents.- Coverage windows differ per city. Always read
data_earliest/data_latestfrom /cities instead of assuming a range. Some feeds also lag their source by days or weeks, and at least one is frozen upstream — an empty recent window is usually publication lag, not zero crime. See how current the data is. - Some agencies publish administrative records — warrant service, lost property, mental-health transports, found vehicles — in the same dataset as offenses. We classify these as
Non-Crimeso they are visible rather than silently counted; they exceed 30% of records in a handful of cities. Passexclude_non_crime=truefor crime-only analysis. - Some agencies file one row per offense, not one per incident. 21 of the 41 cities do, so several records legitimately share one
incident_code— a single arrest with three charges is three records — and counting rows over-counts incidents. Two places tell you which cities:meta.multiple_records_per_incidenton every response, and the Rows per incident column in the City codes table. Countrecord_idfor offenses andDISTINCT incident_codefor incidents. This is not a filter you can turn off, deliberately: both views are legitimate, and collapsing the rows server-side would throw away the individual charges — which for many consumers are the point. nullis not zero and not false. Anullinincident_arrestmeans the agency publishes no disposition. Counting nulls as "no arrest" invents a clearance rate — andnullis what you will get from all but 1 of the 41 cities. The same applies toincident_domesticand toincident_offense_action.- Coordinates are as published. Many agencies deliberately publish block-level or slightly offset points to protect victim privacy. Do not treat a coordinate as a street address, and be careful with very small radii.
- Offense counts are not crime rates. Recording practices, classification and what each agency chooses to publish all differ.
incident_offenseandincident_offense_severityexist to make cross-city comparison possible; they do not make it automatic. - Canadian cities report under Canadian UCR, not FBI/NIBRS. Montreal, Ottawa, Toronto publish under the Canadian Uniform Crime Reporting survey — a different coding system from the FBI's.
incident_offenseandincident_offense_codeare our canonical classification, derived the same way for every city, so they stay comparable across the border. But a Canadian city'sincident_source_original_code(TOR) is a raw Canadian UCR code and is not comparable to a US city's raw code. Group and compare onincident_offense; never on rawincident_source_original_codeacross countries.
Questions, a higher rate limit, or a field you need that is not here — get in touch. If you are integrating something ambitious, tell us and we will sanity-check the approach.