Offendersearch
Map API Reference

Access & keys

A secret key from your server, a browser key for your own sites, and phone apps.

Base URL https://api.offendersearch.app

Which key to use

Where the call is madeKeyNotes
Your serverYour secret keyWorks on every route. The browser talks to your server; your server talks to us.
The browser, on your own siteA map browser key, os_map_pk_…Works only on the map routes listed below, and only from the sites on its list.
A phone appYour secret key, on your serverA phone app has no web origin, so it calls through your server.

Every request sends the key in the X-API-Key header. A secret key must never be in a web page or an app bundle.

From your server

A small proxy keeps the secret key on your server. It passes the map read routes and the area route through, with the caching headers, and nothing else. Both samples below are complete.

server.js — Node
// server.js — Node 18+, Express
import express from 'express';

const app = express();
const API = 'https://api.offendersearch.app';
// Only the map read routes are passed through.
const READ = /^\/(viewport|groups\/[^/]+|records\/[^/]+)$/;

app.use('/v1/map', express.json(), async (req, res) => {
  const isRead = req.method === 'GET' && READ.test(req.path);
  const isArea = req.method === 'POST' && req.path === '/area';
  if (!isRead && !isArea) return res.sendStatus(404);
  // Check here that the caller is signed in to YOUR app.

  const headers = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY };
  const etag = req.get('If-None-Match');
  if (etag) headers['If-None-Match'] = etag;
  // Optional: your own id for the signed-in user.
  if (req.user) headers['X-End-User'] = String(req.user.id);
  const init = { method: req.method, headers };
  if (isArea) {
    headers['Content-Type'] = 'application/json';
    init.body = JSON.stringify(req.body);
  }

  const query = req.originalUrl.split('?')[1];
  const url = API + '/v1/map' + req.path + (query ? '?' + query : '');
  const upstream = await fetch(url, init);

  res.status(upstream.status);
  for (const name of ['content-type', 'etag', 'cache-control', 'retry-after']) {
    const value = upstream.headers.get(name);
    if (value) res.set(name, value);
  }
  res.send(Buffer.from(await upstream.arrayBuffer()));
});

app.listen(3000);
server.py — Python
# server.py — Flask
import os

import requests
from flask import Flask, Response, abort, request

app = Flask(__name__)
API = "https://api.offendersearch.app"
# Only the map read routes are passed through.
READ = ("viewport", "groups/", "records/")
KEEP = ("Content-Type", "ETag", "Cache-Control", "Retry-After")


@app.route("/v1/map/<path:rest>", methods=["GET", "POST"])
def map_proxy(rest):
    is_read = request.method == "GET" and rest.startswith(READ)
    is_area = request.method == "POST" and rest == "area"
    if not (is_read or is_area):
        abort(404)
    # Check here that the caller is signed in to YOUR app.

    headers = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
    etag = request.headers.get("If-None-Match")
    if etag:
        headers["If-None-Match"] = etag

    upstream = requests.request(
        request.method,
        f"{API}/v1/map/{rest}",
        params=request.args,
        json=request.get_json() if is_area else None,
        headers=headers,
        timeout=20,
    )
    passed = {h: upstream.headers[h] for h in KEEP if h in upstream.headers}
    return Response(
        upstream.content, status=upstream.status_code, headers=passed
    )
  • Check your own sign-in first. An open proxy lets anyone spend your map requests.
  • Pass If-None-Match up and ETag down. An unchanged view then answers 304 and is not billed.
  • Optionally send X-End-User. It is any id you choose for the signed-in person. The daily area limit is then also counted for each end user, so one heavy user is limited before the whole account is. See Limits & errors.

From the browser: map browser keys

A map browser key starts with os_map_pk_. It is safe to put in a web page because it only works from the sites you list, and only on the map routes below.

Create one

Create a map browser key in the dashboard, or with a dashboard session:

POST /v1/keys
curl -X POST https://api.offendersearch.app/v1/keys \
  -H "Authorization: Bearer $DASHBOARD_SESSION" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Store locator map",
    "kind": "map_publishable",
    "allowedOrigins": [
      "https://example.com",
      "https://*.example.com",
      "http://localhost:8000"
    ]
  }'

To change the list of sites later, send the whole new list:

PUT /v1/keys/{id}/origins
curl -X PUT https://api.offendersearch.app/v1/keys/KEY_ID/origins \
  -H "Authorization: Bearer $DASHBOARD_SESSION" \
  -H "Content-Type: application/json" \
  -d '{"allowedOrigins": ["https://example.com"]}'

Allowed sites

  • Each entry is a scheme and a host, such as https://example.com. Include the port when there is one.
  • https://*.example.com matches every subdomain of example.com. It does not match example.com itself, so list both.
  • http:// is for localhost only, such as http://localhost:8000.
  • The browser sends the page’s site in the Origin header. A request from a site that is not on the list answers 403 origin_not_allowed.

Where a browser key works

RouteBrowser key
GET /v1/map/viewportYes
POST /v1/map/areaYes
GET /v1/map/groups/{groupId}Yes
GET /v1/map/records/{markerId}Yes
GET /v1/map/statusYes
Every other route in the APINo. It answers 401.

The map routes answer browser preflight requests and set Access-Control-Allow-Origin for an allowed site. The ETag and Retry-After response headers are readable from the page.

Phone apps

A phone app has no web origin, so a browser key cannot be checked against a site. Call from your server with the secret key, using the same proxy as above. Your app signs its own users in to your server.

Dashboard sessions

The map routes also accept a dashboard session in Authorization: Bearer. That is how the dashboard’s own map works. Use a key in your own app.

Product overview