Access & keys
A secret key from your server, a browser key for your own sites, and phone apps.
Base URL https://api.offendersearch.appWhich key to use
| Where the call is made | Key | Notes |
|---|---|---|
| Your server | Your secret key | Works on every route. The browser talks to your server; your server talks to us. |
| The browser, on your own site | A map browser key, os_map_pk_… | Works only on the map routes listed below, and only from the sites on its list. |
| A phone app | Your secret key, on your server | A 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 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 — 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-Matchup andETagdown. An unchanged view then answers304and 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:
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:
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.commatches every subdomain ofexample.com. It does not matchexample.comitself, so list both.http://is for localhost only, such ashttp://localhost:8000.- The browser sends the page’s site in the
Originheader. A request from a site that is not on the list answers403 origin_not_allowed.
Where a browser key works
| Route | Browser key |
|---|---|
GET /v1/map/viewport | Yes |
POST /v1/map/area | Yes |
GET /v1/map/groups/{groupId} | Yes |
GET /v1/map/records/{markerId} | Yes |
GET /v1/map/status | Yes |
| Every other route in the API | No. 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.