Offendersearch
GeoJSON · 58 US registries

Sex offender GeoJSON, ready for a map layer.

Sex offender GeoJSON for whatever area your map is showing. Add format=geojson to a Map API request and the answer is a FeatureCollection of points: clusters when zoomed out, pins closer in, names close up. Hand it to a map source as it is.

$2.00 per 1,000 map requests · $0.015 per record opened · $300 monthly minimum · Test mode is free

curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-88.3,41.5,-87.2,42.2 \
  -d zoom=9 \
  -d format=geojson \
  -d sandbox=true
The output

What sex offender GeoJSON looks like

One FeatureCollection per map view. Every feature is a Point, with coordinates in GeoJSON order: longitude first.

Cluster features

Below zoom 13. A count for an area, with cluster: true and the property names cluster layers already read.

Pin features

From zoom 13. One feature per registered address, with kind: "marker". From zoom 15 it carries a label: first initial and last name.

Area features

kind: "area". People placed only to the middle of a ZIP code, city or county. Style them differently from an exact pin.

Everything that is not a feature is under meta: the mode, the zoom used, the total, the count of people with no mappable address, and the notice to show on the map. The content type is application/geo+json.

Cluster properties

The property names on a cluster feature

They are the names MapLibre and Mapbox GL cluster layers style, so an existing cluster layer works without changes.

PropertyTypeWhat it holds
clustertrueMarks the feature as a cluster. Pins and areas do not have it.
cluster_idstringThe id of the cluster. Use it as a key.
point_countnumberPeople in the cluster. Also the weight for a heat map layer.
point_count_abbreviatedstringThe count shortened for a label: 412, 1.8k, 12k.
expansion_zoomnumberThe zoom at which the cluster comes apart.
bboxarrayWest, south, east, north: the extent of the people in the cluster.
breakdown_streetnumberOf the count, people at an exact street address.
breakdown_housednumberHousing status, where the registry states it.
breakdown_homelessnumberAs above.
breakdown_incarceratednumberAs above.

The breakdown is flat on purpose. A style expression can read ["get", "breakdown_homeless"] directly, so a filtered count needs no second request.

Pins and areas

Marker, group and area features

Tell them apart with kind. The other properties are the same fields the JSON format returns.

{
  "type": "Feature",
  "geometry": {
    "type": "Point",
    "coordinates": [-87.62981, 41.88123]
  },
  "properties": {
    "kind": "marker",
    "id": "mk_Zb3…",
    "precision": "street",
    "addressType": "home",
    "label": "J. Smith",
    "housingStatus": "housed",
    "hasPhoto": true,
    "registryState": "IL"
  }
}
  • marker: one person at one address. Its id opens the full record.
  • group: several people at one address. id is null; groupId lists who is there.
  • area: a count at the middle of a ZIP code, city or county. Never draw it as an exact address.

A view with more than 300 pins is answered as cluster features on a finer grid, so one collection never grows past what a layer draws smoothly.

Turn on the Map APITest mode is free and needs no plan.
MapLibre

Draw sex offender GeoJSON on a MapLibre map

A source, three layers and one function that asks again when the map stops moving. Mapbox GL takes the same code.

import { Map } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

// "/api/map/viewport" is a route on YOUR server.
// It adds the secret key and passes the request on.
const map = new Map({
  container: 'map',
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [-87.6298, 41.8781],
  zoom: 10,
});

map.on('load', () => {
  // The clusters arrive ready-made: no "cluster: true" here.
  map.addSource('people', {
    type: 'geojson',
    data: { type: 'FeatureCollection', features: [] },
  });
  map.addLayer({
    id: 'clusters',
    type: 'circle',
    source: 'people',
    filter: ['has', 'point_count'],
    paint: {
      'circle-color': '#1d4ed8',
      'circle-radius': ['step', ['get', 'point_count'], 16, 50, 22, 500, 30],
    },
  });
  map.addLayer({
    id: 'cluster-count',
    type: 'symbol',
    source: 'people',
    filter: ['has', 'point_count'],
    layout: {
      'text-field': ['get', 'point_count_abbreviated'],
      'text-font': ['Noto Sans Regular'],
      'text-size': 12,
    },
    paint: { 'text-color': '#ffffff' },
  });
  map.addLayer({
    id: 'pins',
    type: 'circle',
    source: 'people',
    filter: ['!', ['has', 'point_count']],
    paint: { 'circle-color': '#b91c1c', 'circle-radius': 6 },
  });
  refresh();
});

map.on('moveend', refresh);

async function refresh() {
  const b = map.getBounds();
  const query = new URLSearchParams({
    bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','),
    zoom: String(Math.floor(map.getZoom())),
    format: 'geojson',
  });
  const res = await fetch('/api/map/viewport?' + query);
  if (res.ok) map.getSource('people').setData(await res.json());
}

The source is a plain GeoJSON source. Leave the library’s own clustering off: the features are already clustered for the zoom you sent, and counting them again would group clusters of clusters.

A click on a cluster fits the map to its bbox at its expansion_zoom. The next moveend then asks for the new view.

map.on('click', 'clusters', (e) => {
  const p = e.features[0].properties;
  // MapLibre hands array properties back as JSON text.
  const [west, south, east, north] = JSON.parse(p.bbox);
  const cam = map.cameraForBounds([[west, south], [east, north]], {
    padding: 48,
  });
  map.easeTo({
    center: cam.center,
    zoom: Math.max(p.expansion_zoom, Math.min(cam.zoom, 17)),
  });
});

The complete map, with popups, the many-people list and the record panel, is built step by step in how to build a sex offender map.

Other libraries

Leaflet, Google Maps and heat maps

Leaflet

Pass the collection to L.geoJSON and choose the marker in pointToLayer from properties.cluster and properties.kind.

Google Maps

map.data.addGeoJson takes it as it is. Set the icon in map.data.setStyle with a function of the feature.

Heat maps

Feed the cluster features to a heat map layer and use point_count as the weight. No pins are needed for it.

Scope

A view at a time, not a file

The GeoJSON is an answer for one map view. It is not a download of a state or of the country. That is what keeps a map quick: a view of a whole region is a few dozen cluster features, and a street is a few pins.

It is also the licence. Map data is for display in your own application, and a result may be cached for a short time, not kept as a copy. The response says so in the fields it sends: meta.notice is the line to show beside the map. “A pin shows a registered address, not a person's current location.”

The format costs nothing extra. A map request is one answer for one map view. It costs the same whether the view holds three people or three hundred. Prices, the monthly minimum and how to turn the Map API on are on the sex offender map API page.

FAQ

Sex offender GeoJSON: common questions

Is there a sex offender GeoJSON file I can download?

Not as a file. The GeoJSON is returned per map view by the Map API, so your map always asks for the area on screen and gets clusters or pins for it. A whole-state or whole-country file is not offered.

Which registries does the GeoJSON cover?

58 US registries: the 50 states, DC and the US territories. One request covers whatever part of them is in view.

Which way round are the coordinates?

Longitude first, then latitude, as the GeoJSON standard requires. The bbox property is west, south, east, north.

Should I turn on clustering in my map library?

No. The features are already clustered for the zoom you sent, and each cluster carries its count. Leave the library’s own clustering off and style on point_count.

Why is the bbox property a string when I read it from a clicked feature?

MapLibre and Mapbox GL turn array and object properties into JSON text on rendered features. Parse it with JSON.parse before use. The response itself holds a real array.

Does the GeoJSON include names?

From zoom 15, each pin has a label: a first initial and a last name. Wider views hold positions and counts only. The full record comes from a separate request when a pin is opened.

Does GeoJSON cost more than JSON?

No. Either format is one map request: $2.00 per 1,000 map requests.

Can I try it without paying?

Test mode returns made-up people in the real response shape. Add sandbox=true to any map request. It needs no plan and is never charged.

How current is the data?

It is the same continuously updated registry data the search API returns. A record opened from the map carries its own lastCheckedAt.

Give your map a feature collection

Build against test mode today, then turn the Map API on when the map is ready for real records.

The Offendersearch APIs

One account, one key, one bill — the sex offender registry and criminal records, side by side.

Sex offender registry

Criminal records

Build with it

Screening and compliance