# Supply API — Read-Only Coverage Availability

Machine-readable documentation for approved partners and publishers.

This document describes the **only** read-only public coverage endpoint that
exists in this project: `GET /api/public/coverage`. The partner-facing name is
**"Supply API"**. It exposes current **aggregate** roofing-market availability
by ZIP code only. It does **not** accept contractor, client, ZIP, cap, pricing,
or any inbound submission — it is strictly read-only. For inbound lead posting,
see the separate Publisher Lead API (`POST /api/public/partner-lead`).

> **Scope:** This endpoint returns a snapshot of which ZIP codes are currently
> accepting leads for a service. No response ever contains a client name,
> internal record id, SQL text, cap, price, contact detail, or lead field.

## Endpoint

| Method  | Path                    | Purpose                                      |
|---------|-------------------------|----------------------------------------------|
| GET     | `/api/public/coverage`  | Read current accepting ZIP codes             |
| OPTIONS | `/api/public/coverage`  | CORS preflight (returns `204`)               |
| POST    | `/api/public/coverage`  | Not allowed (returns `405`)                  |

Requests are HTTP GET. Responses are JSON (`Content-Type: application/json`).

## Authentication

**None.** This endpoint is unauthenticated and public. It does not use a key,
token, or publisher credential. Access is controlled only by the CORS origin
allowlist and a per-IP rate limit (described below).

## Query parameters

| Parameter | Required | Values                              | Behavior                                                          |
|----------|----------|-------------------------------------|-------------------------------------------------------------------|
| `service`| No       | `roofing` (default), `siding`, `windows` | Selects the coverage service. Unsupported values return `400`. |
| `map`    | No       | `1` / `true` / `yes`                 | When set, adds a `map` block with centroid coordinates per ZIP. |

- When `service` is omitted or blank, the launch service (`roofing`) is used.
- An unrecognized `service` value returns `400 unsupported_service`.
- `map` is opt-in only. Without it, the response omits the `map` block entirely.

## Response schema

### Success (`200`)

```json
{
  "schema": "osa-coverage/v1",
  "service": "roofing",
  "zips": ["20170", "20171", "22182"],
  "count": 3,
  "generated_at": "2026-09-22T13:38:00.000Z",
  "cache_seconds": 300
}
```

With `?map=1`, the same response gains a `map` block:

```json
{
  "schema": "osa-coverage/v1",
  "service": "roofing",
  "zips": ["20170", "20171", "22182"],
  "count": 3,
  "generated_at": "2026-09-22T13:38:00.000Z",
  "cache_seconds": 300,
  "map": {
    "points": [
      { "zip": "20170", "state": "VA", "lat": 38.8235, "lng": -77.4153 },
      { "zip": "22182", "state": "VA", "lat": 38.9173, "lng": -77.2324 }
    ],
    "mapped_count": 2,
    "unmapped_count": 1
  }
}
```

| Field          | Type     | Meaning                                                              |
|----------------|----------|----------------------------------------------------------------------|
| `schema`       | string   | Always `osa-coverage/v1`.                                            |
| `service`      | string   | The requested service.                                               |
| `zips`         | string[] | Sorted, unique, US five-digit ZIP codes currently accepting leads.  |
| `count`        | number   | Length of `zips`.                                                     |
| `generated_at` | string   | UTC ISO 8601 timestamp when the snapshot was computed.                |
| `cache_seconds`| number   | Bounded freshness window for shared/edge caches (300).                |
| `map`          | object   | Present only for `?map=1`. See map fields below.                      |

### Map block fields

| Field           | Type     | Meaning                                                              |
|-----------------|----------|----------------------------------------------------------------------|
| `points`        | object[] | One point per accepting ZIP with a valid authoritative centroid.     |
| `points[].zip`  | string   | The five-digit ZIP.                                                  |
| `points[].state`| string   | Two-letter US state abbreviation.                                    |
| `points[].lat`  | number   | Latitude from the authoritative centroid table.                      |
| `points[].lng`  | number   | Longitude from the authoritative centroid table.                    |
| `mapped_count`  | number   | Accepting ZIPs that could be plotted.                                |
| `unmapped_count`| number   | Accepting ZIPs with no usable centroid; `mapped + unmapped === count`. |

Map coordinates are read only from the authoritative `zip_centroids` table —
no runtime geocoder is called. A ZIP absent from the accepting list can never
appear as a point. Non-finite or out-of-range coordinates are dropped and
counted as `unmapped`, never guessed.

## CORS

The endpoint sets strict CORS headers based on an exact origin allowlist
derived from the configured site origins (`PUBLIC_SITE_ORIGINS`) plus local
development origins. Approved origins are echoed back in
`Access-Control-Allow-Origin`; any other origin receives a fallback origin and
the request is still answered (the body is public) but the browser's same-origin
policy prevents reading it from the disallowed origin.

Allowed headers: `content-type`. Allowed methods: `GET, OPTIONS`.
`Access-Control-Max-Age: 86400`. All responses carry `Vary: Origin`.

## Caching

Successful `200` responses are cacheable:

```
Cache-Control: public, max-age=60, s-maxage=300
```

The `map` query parameter is part of the cache key, and a
`X-Osa-Coverage-Variant` response header (`zips` or `map`) distinguishes the
two variants so a zips-only body is never served to a map request.

Error responses carry `Cache-Control: no-store`.

## Rate limiting

A distributed rate limit of **30 requests per 60 seconds** is keyed by the
hashed caller IP (from `cf-connecting-ip` or `x-forwarded-for`). Exceeding it
returns `429 rate_limited`.

## Errors

All error bodies share this shape:

```json
{ "schema": "osa-coverage/v1", "error": "<code>" }
```

| Status | `error`              | Meaning                                            |
|--------|----------------------|----------------------------------------------------|
| 400    | `unsupported_service`| Unrecognized `service` value.                       |
| 405    | `method_not_allowed` | Non-GET method (POST, PUT, DELETE, etc.).          |
| 429    | `rate_limited`       | Too many requests from this IP.                    |
| 503    | `unavailable`        | Upstream/infra read failure; fails closed (never a partial list). |

On any read failure the endpoint **fails closed**: it returns `503 unavailable`
rather than a partial or stale ZIP list.

## Important limitations

- **Read-only.** This endpoint never accepts submissions of any kind. It does
  not create, update, or delete contractors, clients, ZIP coverage, caps, or
  pricing. For inbound lead posting, use the Publisher Lead API.
- **Conservative coverage.** The published list reflects only ZIPs currently
  accepting leads from routing-eligible clients with remaining daily capacity.
  Proximity-radius coverage extends routing at request time out of the same
  authoritative ZIPs but has no finite ZIP list, so it is **not** enumerated. A
  ZIP may still route even when absent from this list.
- **No business-hours effect.** Being outside business hours never removes a
  ZIP; overnight submissions queue for the client-local 8:00 AM window.

## Example — list accepting ZIPs

```bash
curl https://<your-site-origin>/api/public/coverage?service=roofing
```

## Example — map mode

```bash
curl https://<your-site-origin>/api/public/coverage?service=roofing&map=1
```

Replace `<your-site-origin>` with the site origin provided during onboarding.
All example values above are placeholders.

## Onboarding

This endpoint is public and unauthenticated, so no key is required. Partners
may use it to display aggregate coverage availability. To discuss access or
commercial terms, contact the Pro Roofers USA partnerships team.
