Public data and contact API

Endpoint contracts, field definitions and request/response examples for crossings, city trends and private inquiries.

List crossing records

GET/api/crosswalks
Public · no sign-inCrossingsRead only

Returns the recorded crossing model for one city or all available cities, ordered by crosswalk_id. Measurements such as length_m and width_m are numbers or null. This endpoint returns records, not the computed safety assessment shown in the application.

Query parameters

citystringOptional
Exact city slug, such as the slug used in the app. A numeric city ID is not accepted as an ID here. Omit or leave empty to include all cities.
Response propertyTypeMeaning
crosswalksCrosswalk[]Matching crossing records; empty when the route reports an error.
crosswalks[].crosswalk_id / city_idnumberCrossing identifier and parent city identifier.
crosswalks[].city / street / namestringCity, street and crossing display names.
crosswalks[].latitude / longitudenumberLocation in decimal degrees.
crosswalks[].length_m / width_mnumber | nullRecorded dimensions in metres; null means unavailable.
errorstring | nullnull on success; a message on failure.

Responses

HTTP statusMeaning
200The requested dataset was loaded.
404The city slug could not be resolved. This can also happen if the city lookup is unavailable.
502The data service failed, or the requested dataset contains no crossing records.

Request and response examples

Request · city filterjavascript
const query = new URLSearchParams({ city: 'YOUR_CITY_SLUG' })
const response = await fetch('/api/crosswalks?' + query)
const { crosswalks, error } = await response.json()
if (!response.ok || error) throw new Error(error ?? 'Request failed')
200 response · synthetic, selected fieldsjson
{
  "crosswalks": [
    {
      "crosswalk_id": 1001,
      "city_id": 1,
      "city": "Example City",
      "street": "Example Street",
      "name": "Library crossing",
      "latitude": 49.22,
      "longitude": 18.74,
      "length_m": 8.5,
      "width_m": null,
      "has_lighting": true,
      "has_curb_ramp": true
    }
  ],
  "error": null
}
404 response · unresolved cityjson
{
  "crosswalks": [],
  "error": "Unknown city 'missing-city'."
}

The success example shows selected fields only. Actual records also include infrastructure, inspection and evaluation attributes. No page or pageSize query is exposed; the server loads the matching records in batches and returns them together.

Read city score history

GET/api/score-history
Public · no sign-inAnalyticsHistory

Returns one aggregate point per snapshot capture date, oldest first. Historical rows are evaluated with the current scoring engine, so a scoring calibration can change previously displayed averages. The count shows how many crossings contributed on that date.

Query parameters

citystringOptional
A city slug or an all-digit city ID, for example 1. Omit or leave empty to aggregate all cities.
Response propertyTypeMeaning
pointsCityTrendPoint[]Aggregate points; an empty array is valid when no snapshots match.
points[].capturedOnstringSnapshot date in YYYY-MM-DD format.
points[].avgSafetynumberRounded average safety score.
points[].avgAccessibilitynumberRounded average accessibility score.
points[].avgConfidencePctnumberRounded average share of safety-score weight backed by surveyed data, as a percentage.
points[].countnumberNumber of crossing snapshots used for the date.
errorstring | nullnull on success; a message on failure.

Responses

HTTP statusMeaning
200Trend points were computed. A numeric ID without snapshots can return points: [].
404The supplied city slug could not be resolved.
502Snapshot records could not be loaded.

Request and response examples

Request · numeric city IDjavascript
const response = await fetch('/api/score-history?city=1')
const result = await response.json()
if (!response.ok || result.error) throw new Error(result.error ?? 'Request failed')
console.log(result.points)
200 response · synthetic trendjson
{
  "points": [
    {
      "capturedOn": "2026-08-01",
      "avgSafety": 68,
      "avgAccessibility": 72,
      "avgConfidencePct": 81,
      "count": 120
    },
    {
      "capturedOn": "2026-09-01",
      "avgSafety": 71,
      "avgAccessibility": 75,
      "avgConfidencePct": 84,
      "count": 124
    }
  ],
  "error": null
}
502 response · storage read failurejson
{
  "points": [],
  "error": "Unable to load score history."
}

The handler reads at most 100,000 snapshot rows, in chronological order. Large histories can therefore omit later snapshots once this cap is reached; there is no continuation token. Counts can vary as the recorded dataset changes.

Submit a private inquiry

POST/api/contacts
Public · origin checkedContactsJSON bodyRate limited

Stores a private contact inquiry and returns an acknowledgement. It does not return the saved record or send an email automatically. Browser requests must come from the same origin. Non-browser clients may omit Origin; cross-site requests and mismatching Origin values are rejected.

Send Content-Type: application/json and keep the entire body within 24,000 bytes. Names, email, organization and message are trimmed; email is lowercased before storage. Only send privacyAccepted: true after the person has acknowledged the privacy notice.

JSON request body

namestringRequired
2–120 characters after trimming. No line breaks or control characters.
emailstringRequired
Email address, at most 254 characters. Must include a local part, @ and a dotted domain.
organizationstringOptional
At most 160 characters after trimming; no line breaks or control characters.Default: ""
topicenumRequired
demo | hosted | self_hosted | partnership | other
messagestringRequired
20–5,000 characters after trimming. Line breaks are allowed; null characters are rejected.
localeenumRequired
sk | en
privacyAcceptedbooleanRequired
Must be the boolean true, not a string.
websitestringOptional
Anti-spam honeypot. Omit it or leave it empty; a truthy value is acknowledged without saving the inquiry.

Responses

HTTP statusMeaning
201Saved successfully. Body: { "ok": true }.
200Honeypot filled: acknowledged with { "ok": true }, but no record is saved.
400error: "invalid_json" for malformed JSON or wrong content type; error: "validation" with field for invalid input.
403error: "invalid_origin". The request is cross-site or its Origin does not match.
413error: "too_large". Request body exceeds 24,000 bytes.
429error: "rate_limited" with Retry-After: 3600. The hourly email or overall limit was reached.
503error: "unavailable". Storage is not available.

Request and response examples

Request · same-origin browser, synthetic contactjavascript
const response = await fetch('/api/contacts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Alex Example',
    email: 'alex@example.com',
    organization: 'Example City',
    topic: 'demo',
    message: 'We would like a demonstration for our city mobility team.',
    locale: 'en',
    privacyAccepted: true,
  }),
})
const result = await response.json()
if (!response.ok) {
  console.error(response.status, result.error, result.field)
}
201 response · inquiry savedjson
{
  "ok": true
}
400 response · invalid emailjson
{
  "error": "validation",
  "field": "email"
}
429 response · wait before resubmittinghttp
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 3600

{"error":"rate_limited"}

The current storage policy permits 3 submissions per email in a rolling hour and 100 submissions overall in a rolling hour. Validation returns the first failing field. Status and internal admin notes are assigned by the service, not accepted from a public submission.