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 property
Type
Meaning
crosswalks
Crosswalk[]
Matching crossing records; empty when the route reports an error.
crosswalks[].crosswalk_id / city_id
number
Crossing identifier and parent city identifier.
crosswalks[].city / street / name
string
City, street and crossing display names.
crosswalks[].latitude / longitude
number
Location in decimal degrees.
crosswalks[].length_m / width_m
number | null
Recorded dimensions in metres; null means unavailable.
error
string | null
null on success; a message on failure.
Responses
HTTP status
Meaning
200
The requested dataset was loaded.
404
The city slug could not be resolved. This can also happen if the city lookup is unavailable.
502
The data service failed, or the requested dataset contains no crossing records.
{
"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.
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 property
Type
Meaning
points
CityTrendPoint[]
Aggregate points; an empty array is valid when no snapshots match.
points[].capturedOn
string
Snapshot date in YYYY-MM-DD format.
points[].avgSafety
number
Rounded average safety score.
points[].avgAccessibility
number
Rounded average accessibility score.
points[].avgConfidencePct
number
Rounded average share of safety-score weight backed by surveyed data, as a percentage.
points[].count
number
Number of crossing snapshots used for the date.
error
string | null
null on success; a message on failure.
Responses
HTTP status
Meaning
200
Trend points were computed. A numeric ID without snapshots can return points: [].
404
The supplied city slug could not be resolved.
502
Snapshot 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)
{
"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.
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 status
Meaning
201
Saved successfully. Body: { "ok": true }.
200
Honeypot filled: acknowledged with { "ok": true }, but no record is saved.
400
error: "invalid_json" for malformed JSON or wrong content type; error: "validation" with field for invalid input.
403
error: "invalid_origin". The request is cross-site or its Origin does not match.
413
error: "too_large". Request body exceeds 24,000 bytes.
429
error: "rate_limited" with Retry-After: 3600. The hourly email or overall limit was reached.
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.