# Access & keys

> Which key to use for the Map API: a secret key from your server with a small proxy, or a browser key limited to your own sites. Plus how phone apps call it.

- **HTML:** https://offendersearch.app/docs/map/access
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **Map API reference as markdown:** https://offendersearch.app/docs/map.md

## Which 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

```javascript
// 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

```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](https://offendersearch.app/docs/map/limits-and-errors.md).

## 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

```bash
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

```bash
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

| 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.

---

## Related

- Previous: [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md)
- Next: [Test mode](https://offendersearch.app/docs/map/test-mode.md)
- Index: [Map API reference](https://offendersearch.app/docs/map.md)
