Management API v1
Complete reference for the authenticated TrailHUB Management API: trail systems, trails, points, updates, API keys, errors and examples.
The Management API lets trail managers, and tools acting for them, read and change their trail systems programmatically. It works on the same records as the web app, and every write queues a recompile so the public map, stats, embeds and subscriber notifications stay in sync.
Base URL: https://trailhub.org/api/v1
The API is available to every manager account; create an API key under User Settings → API Keys (see API keys). There is no plan gate and, currently, no rate limit. The older unauthenticated endpoints are documented separately in Public read API.
#Authentication
Send one credential in the Authorization header:
Authorization: Bearer th_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # API key
Authorization: Bearer <Firebase ID token> # signed-in managerX-API-Key: th_… is accepted instead of Authorization for clients that cannot set it. Anything starting with th_ is treated as an API key; anything else is verified as a Firebase ID token from a TrailHUB session.
| Credential | Scopes | Can manage keys | Typical use |
|---|---|---|---|
| API key | As set on the key (read, or read + write) | No | Scripts, cron jobs, the MCP server |
| Firebase ID token | Always read + write | Yes | The web app itself; browser code running inside a signed-in session |
The key-management endpoints (/keys) accept only an ID token, so a leaked API key cannot create more keys.
Two endpoints need no credentials: GET /api/v1 (endpoint list and enum values) and GET /api/v1/update-types (fields of each update type).
CORS is open for /api/v1 (any origin may call it), unlike the legacy public endpoints.
#Permissions and roles
Access to a trail system mirrors the web app. Your role is evaluated per trail system on every request:
| Role | How you get it | Can |
|---|---|---|
| Administrator | You are the system's ownerId; or permissions[yourUid] === "Administrator"; or your account is a TrailHUB superAdmin | Everything below, plus PATCH /trail-systems/:id |
Trustee (or another named role in permissions) | Listed in the system's users[] or permissions map | Read and write trails, points and updates; force a recompile |
| none | — | 403 forbidden |
On top of the role, the key's scope applies: a read-only key can only call GET endpoints (403 insufficient_scope otherwise), and a key restricted to specific trail systems gets 403 key_restricted for any other system. A superAdmin may list every system with GET /trail-systems?all=true.
#Conventions
JSON in and out. Send
Content-Type: application/json. Bodies up to 2 MB.Dates are ISO 8601 strings in responses (
"2026-10-01T14:03:11.000Z") and accepted in any formnew Date()parses in requests.Coordinates are
{ "lat": 42.41, "lng": -79.31 }objects in responses. Point endpoints acceptlat/lng(orlatitude/longitude). GeoJSON geometry uses[lng, lat]as the spec requires.PATCHchanges only the fields you send. Sending no editable field returns400 no_changes.IDs are opaque strings. A trail system's ID is in its TrailHUB URL.
Successful responses:
200with the resource,201for creations,202for a queued compile.Error responses always have the shape
{ "error": { "code": "invalid_field", "message": "status must be one of: Open, Caution, Closed, None." } }
#Error codes
| HTTP | code | Meaning |
|---|---|---|
| 400 | missing_field | A required field is absent |
| 400 | invalid_field | Wrong type, out of range, or not in the allowed list |
| 400 | invalid_date | Could not parse a date string |
| 400 | invalid_geometry | Trail geometry is not a usable LineString/MultiLineString |
| 400 | invalid_json | Body is not valid JSON |
| 400 | no_changes | PATCH body contained no editable fields |
| 400 | unknown_activity | Tried to change an activity the trail does not have |
| 400 | unknown_type | Update has a type the API cannot edit |
| 400 | too_many_keys | Already 25 active keys |
| 400 | missing_trail_system | No trail system id in the path |
| 401 | missing_credentials | No Authorization / X-API-Key header |
| 401 | invalid_api_key | Key not recognized |
| 401 | revoked_api_key | Key was revoked |
| 401 | expired_api_key | Key is past its expiry |
| 401 | invalid_token | ID token invalid or expired |
| 403 | id_token_required | /keys called with an API key |
| 403 | insufficient_scope | Write attempted with a read-only key |
| 403 | key_restricted | Key is limited to other trail systems |
| 403 | forbidden | You do not manage this trail system |
| 403 | admin_required | Administrator role needed |
| 404 | trail_system_not_found, trail_not_found, point_not_found, update_not_found, key_not_found | Resource missing (or trail ids not in this system) |
| 404 | not_found | No such route |
| 500 | internal_error | Unexpected server error |
#Enumerations
| Name | Values |
|---|---|
Trail status | Open, Caution, Closed, None (None = derive from activities) |
Point status | Open, Caution, Closed |
difficulty | circle, square, diamond, doubleDiamond, terrainPark, notRated |
Trail type | Standard, Lift |
lineStyle | Solid, Dashed |
markerClass | bar, cabin, dining, dog-park, fallen-tree, first-aid, fishing, generic, hazard, ice, ice-rink, lodge, lodging, mud, parking, parking-busy, parking-very-busy, playground, restaurant, restroom, scenic, star, trailhead, wait-time, water-access, web-cam, yurt |
Update type | notice, surfaceConditions, snowReport |
Notice severity | info, warning, danger |
Surface conditions | compacted, very-compacted, dry, frozen-granular, groomed, skateGroomed, classicGroomed, hard-packed, icy, leaf-covered, light-snow, loose-granular, maintenance-in-progress, muddy, no-snow, packed-powder, powder, raked-leaves, snow-machine-packed, soft, very-soft, track-set, variable-conditions, wet, wet-granular, wet-packed, wet-snow, windblown |
Trail system language | en, fr |
tempMeasurement | fahrenheit, celsius |
Key scopes | read, write |
GET /api/v1 returns these lists as JSON under enums.
#Endpoint summary
| Method | Path | Auth | Scope |
|---|---|---|---|
| GET | / | none | — |
| GET | /update-types | none | — |
| GET | /me | any | read |
| GET | /keys | ID token | — |
| POST | /keys | ID token | — |
| DELETE | /keys/:id | ID token | — |
| GET | /trail-systems | any | read |
| GET | /trail-systems/:id | any | read |
| PATCH | /trail-systems/:id | Administrator | write |
| POST | /trail-systems/:id/compile | any | write |
| GET | /trail-systems/:id/weather | any | read |
| GET | /trail-systems/:id/trails | any | read |
| POST | /trail-systems/:id/trails | any | write |
| POST | /trail-systems/:id/trails/status | any | write |
| GET | /trails/:id | any | read |
| PATCH | /trails/:id | any | write |
| DELETE | /trails/:id | any | write |
| GET | /trail-systems/:id/points | any | read |
| POST | /trail-systems/:id/points | any | write |
| GET | /points/:id | any | read |
| PATCH | /points/:id | any | write |
| DELETE | /points/:id | any | write |
| GET | /trail-systems/:id/updates | any | read |
| POST | /trail-systems/:id/updates | any | write |
| GET | /updates/:id | any | read |
| PATCH | /updates/:id | any | write |
| DELETE | /updates/:id | any | write |
"any" means an API key or ID token belonging to a manager of the trail system.
#Discovery
#GET /
No auth. Returns name, version, docs, auth, the endpoints list and enums.
#GET /update-types
No auth. Returns { "updateTypes": [ … ] }, one entry per update type with type, label, description, attachToTrails and fields[] (property, element, label, help, required, options where applicable). Use it to build forms or validate input.
#Account and keys
#GET /me
Who the credential belongs to.
{
"uid": "abc123",
"email": "manager@example.org",
"authType": "apiKey",
"scopes": ["read", "write"],
"superAdmin": false,
"key": { "id": "k8sV…", "name": "Grooming script", "prefix": "th_AbCdEfGh", "trailSystemIds": ["TS_ID"] }
}authType is apiKey or idToken; key is null for ID tokens. trailSystemIds is null when the key is unrestricted.
#GET /keys — ID token only
{ "keys": [ … ] }, newest first. Each key: id, name, prefix, scopes, trailSystemIds, createdOn, lastUsedOn, expiresAt, revoked. Revoked keys are included with revoked: true.
#POST /keys — ID token only
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | yes | 1–100 characters |
scopes | string[] | no | Subset of read, write; default both |
trailSystemIds | string[] | no | Each must be a system you manage |
expiresInDays | number | no | 1–3650 |
Returns 201 with the key record plus key (the plaintext, shown once). Fails with too_many_keys at 25 active keys.
#DELETE /keys/:id — ID token only
Revokes the key. Returns { "ok": true, "id": "…" }. 404 key_not_found if the key is not yours.
#Trail systems
#GET /trail-systems
Systems you manage (owner or listed in users[]), filtered by the key's restriction, sorted by name. SuperAdmins may add ?all=true for every system (up to 500).
{
"trailSystems": [
{
"id": "TS_ID",
"name": "Chautauqua Rails to Trails",
"role": "Administrator",
"inactive": false,
"language": "en",
"totalTrails": 14,
"openTrails": 12,
"cautionTrails": 1,
"closedTrails": 1,
"geolocation": { "lat": 42.41, "lng": -79.31 },
"updatedOn": "2026-10-01T12:00:00.000Z"
}
]
}#GET /trail-systems/:id
The full document minus internal fields (permissions, users, geoJsonPath), with id and your role added. Notable fields:
| Field | Type | Notes |
|---|---|---|
name, description, phoneNumber, website | string | Public details |
language | string | en / fr |
depthMeasurement, lengthMeasurement, tempMeasurement | string | Units (inches/centimeters, miles/kilometers, fahrenheit/celsius) |
inactive, hideName, promoteTrailHUB | boolean | |
geolocation | {lat,lng} | |
totalTrails, openTrails, cautionTrails, closedTrails | number | Recomputed on compile; exclude lifts and hidden trails |
totalDistance, openDistance | number | Kilometres |
activities | array | Per-activity totals |
updates | object | Active system-level updates keyed by type (notice, snowReport) |
weather | object | Hourly snapshot (paid tiers) or absent |
subscription | string | Plan key |
createdOn, updatedOn, updatedBy, ownerId |
#PATCH /trail-systems/:id — Administrator only
| Field | Type | Rules |
|---|---|---|
name, description, phoneNumber, website, depthMeasurement, lengthMeasurement | string | ≤ 2000 chars. website gets http:// prefixed if it lacks a scheme |
language | string | en or fr |
tempMeasurement | string | fahrenheit or celsius |
inactive, hideName, promoteTrailHUB | boolean | inactive: true hides the system from the public map |
Returns the updated document. Does not trigger a recompile (these fields are not in the GeoJSON).
#POST /trail-systems/:id/compile
Queues a rebuild of the published GeoJSON and stats. Returns 202 { "ok": true, "compileRequestId": "…" }. Normally unnecessary: every write below already does this.
#GET /trail-systems/:id/weather
{ "trailSystemId": "…", "weather": { … } | null }. The stored hourly snapshot (OpenWeather current-conditions format, metric units) is only collected for paid tiers; otherwise null.
#Trails
#Trail object
| Field | Type | Notes |
|---|---|---|
id | string | |
trailSystemId | string | |
name | string | ≤ 200 chars |
description | string | ≤ 5000 chars |
status | enum | Open, Caution, Closed, None |
difficulty | enum | default notRated |
type | enum | Standard (default) or Lift |
lineStyle | enum | Solid (default) or Dashed |
hidden, oneWay, oneWayReversed | boolean | default false |
order | number | Lower first. New trails get -(number of existing trails) so they sort to the top |
categories | string[] | |
activities | array | { value, label, iconClass, status, difficulty? } per activity |
distance | number | Kilometres, computed from geometry |
geolocation | {lat,lng} | First vertex |
geoJson | object | Parsed FeatureCollection; only on GET /trails/:id and ?include=geoJson |
createdOn, createdBy, updatedOn, updatedBy |
elevations is stripped from responses.
#GET /trail-systems/:id/trails
{ "trailSystemId", "trails": [ … ] } ordered by order ascending, without geometry. Add ?include=geoJson to include each trail's parsed geometry.
#GET /trails/:id
One trail including parsed geoJson.
#POST /trail-systems/:id/trails
| Field | Required | Notes |
|---|---|---|
name | yes | ≤ 200 chars |
geometry | yes | GeoJSON LineString or MultiLineString with [lng, lat] coordinates |
status, difficulty, description, type, lineStyle, hidden, oneWay, oneWayReversed, order, categories | no | As in the trail object |
Distance is measured from the geometry. The trail is created with no activities; add them in the web app (the API cannot add activities yet). Returns 201 with the trail and queues a recompile.
#PATCH /trails/:id
Any of: name, description, status, difficulty, type, lineStyle, hidden, oneWay, oneWayReversed, order, categories, activities.
activities is an array of { "value", "status"?, "difficulty"? } and may only reference activities already on the trail (400 unknown_activity otherwise); activity status cannot be None. Setting the trail status to Closed also sets every activity to Closed, as the web app does. Queues a recompile.
{ "status": "None", "activities": [ { "value": "xcSkiing", "status": "Open" }, { "value": "snowshoeing", "status": "Closed" } ] }#POST /trail-systems/:id/trails/status
Bulk status change in one write and one recompile.
| Field | Notes |
|---|---|
status | Required; trail status enum |
trailIds | Array of trail ids in this system, or |
all | true for every trail |
Returns { "ok": true, "status": "Closed", "updated": [ { "id", "name" }, … ] }. Unknown ids produce 404 trail_not_found and nothing is changed. Closed closes all activities on each trail.
#DELETE /trails/:id
Moves the trail to the trash (trashedTrails), where a manager can restore it in the web app. Returns { "ok": true, "id", "trashed": true } and queues a recompile.
#Points of interest
#Point object
| Field | Type | Notes |
|---|---|---|
id, trailSystemId | string | |
name | string | ≤ 200 chars |
description | string | |
markerClass | enum | Icon / type |
status | enum | Open (default), Caution, Closed |
hidden | boolean | default false |
waitTime | number | Minutes, integer ≥ 0; for wait-time points |
webCamUrl | string | ≤ 2000 chars; for web-cam points |
diningOptions | boolean/object | Set in the web app |
geolocation | {lat,lng} | |
createdOn, createdBy, updatedOn, updatedBy |
#GET /trail-systems/:id/points
{ "trailSystemId", "points": [ … ] }, newest first.
#POST /trail-systems/:id/points
Required: name, markerClass, lat, lng (−90…90, −180…180). Optional: description, status, hidden, waitTime, webCamUrl. Returns 201; queues a recompile.
#GET /points/:id, PATCH /points/:id, DELETE /points/:id
PATCH accepts any of the create fields; send lat and lng together to move the point. DELETE moves the point to trashedPoints. Both writes queue a recompile.
#Updates
Updates are the time-limited posts managers make: notices, surface conditions and snow reports. Notices and snow reports are system-level; surface conditions attach to trails. Only unexpired updates are published to the map.
#Update object (response)
Common fields: id, trailSystemId, type, label, attachToTrails, expirationDate, notify, relevantTrails (array of { trailId, name, status, difficulty, activities, categories }), trailIds (convenience list of the same ids), createdOn, createdBy, updatedOn, updatedBy, source ("api" when created here), plus the type-specific fields below. Template metadata (fields, en, fr, typeDescription, iconClass) is removed.
#GET /trail-systems/:id/updates
Active (unexpired) updates, soonest-expiring first, up to 100. Add ?includeExpired=true for history.
#POST /trail-systems/:id/updates
Common fields:
| Field | Required | Notes |
|---|---|---|
type | yes | notice, surfaceConditions, snowReport |
expirationDate | no | ISO date in the future; or |
expiresInHours | no | 0 < h ≤ 8760; default 24 when neither is given |
notify | no | true sends email/SMS to every subscriber of the trail system; default false |
trailIds / allTrails | required for surfaceConditions | Trail ids in this system, or allTrails: true. Optional for notice (relates the notice to trails); ignored otherwise |
Type-specific fields:
notice
| Field | Required | Notes |
|---|---|---|
description | yes | The notice text |
severity | no | info (default), warning, danger |
linkUrl | no | "More info" link |
surfaceConditions
| Field | Required | Notes |
|---|---|---|
conditions | yes | Non-empty array from the surface-conditions enum |
snowReport (all optional; amounts in the system's depth unit)
| Field | Type |
|---|---|
baseDepth, twentyFourHours, fourtyEightHours, sevenDays, seasonTotal, lastSnowAmount, upperElevationDepth | number |
lastSnowDate, lastSnowMakingDate | date |
lastSnowTime | "HH:MM" (24 h) |
Returns 201 with the update. The affected trails' updatedOn is touched and a recompile is queued. If notify is true, the notification function emails/texts subscribers exactly as posting from the web app does.
#GET /updates/:id
One update.
#PATCH /updates/:id
Same fields as create (except type). expirationDate or expiresInHours sets a new expiry (from now); trailIds / allTrails replaces the trail list; notify can be changed. Queues a recompile. Note: changing notify on an existing update does not resend notifications; those are sent only when an update is first created.
#DELETE /updates/:id
Removes the update immediately (same effect as letting it expire now). Queues a recompile.
#Examples
Replace TS_ID, trail ids and the key with your own. All examples use an API key with write scope unless stated.
#1. Close every trail and post a notice that notifies subscribers
curl -X POST https://trailhub.org/api/v1/trail-systems/TS_ID/trails/status \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"status":"Closed","all":true}'
curl -X POST https://trailhub.org/api/v1/trail-systems/TS_ID/updates \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"type":"notice","severity":"danger","description":"Closed today due to high winds. We expect to reopen tomorrow morning.","expiresInHours":18,"notify":true}'The first call returns the list of trails it closed; the second returns the new notice. Subscribers receive an email or text within a minute.
#2. Post surface conditions for two trails
curl -X POST https://trailhub.org/api/v1/trail-systems/TS_ID/updates \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"type":"surfaceConditions","conditions":["groomed","track-set"],"trailIds":["TRAIL_A","TRAIL_B"],"expiresInHours":12}'Or for every trail: replace "trailIds":[…] with "allTrails":true.
#3. Create a point of interest
curl -X POST https://trailhub.org/api/v1/trail-systems/TS_ID/points \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"name":"Lot A","markerClass":"parking","lat":42.4100,"lng":-79.3100,"description":"Main lot, 40 cars","status":"Open"}'Later, mark it full:
curl -X PATCH https://trailhub.org/api/v1/points/POINT_ID \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"markerClass":"parking-very-busy","status":"Caution"}'#4. Post a snow report
curl -X POST https://trailhub.org/api/v1/trail-systems/TS_ID/updates \
-H "Authorization: Bearer th_..." -H "Content-Type: application/json" \
-d '{"type":"snowReport","twentyFourHours":6,"fourtyEightHours":9,"baseDepth":18,"seasonTotal":74,"lastSnowDate":"2026-01-14","lastSnowTime":"06:30","expiresInHours":24}'#5. Create an API key with an ID token
Keys can only be minted by a signed-in session. From browser code running inside the TrailHUB app:
const token = await firebase.auth().currentUser.getIdToken()
const res = await fetch('https://trailhub.org/api/v1/keys', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Grooming script', scopes: ['read', 'write'], trailSystemIds: ['TS_ID'], expiresInDays: 365 })
})
const { key } = await res.json() // save it; it is not shown againWith curl, if you already have an ID token:
curl -X POST https://trailhub.org/api/v1/keys \
-H "Authorization: Bearer $ID_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Grooming script","scopes":["read","write"]}'Most people will simply use User Settings → API Keys instead.
#6. Read-only: which trails are closed?
curl -s -H "Authorization: Bearer th_..." https://trailhub.org/api/v1/trail-systems/TS_ID/trails \
| jq '.trails[] | select(.status=="Closed") | {id,name}'#Side effects to know about
- Recompile. Every write to trails, points and updates (and
POST …/compile) queues a compile request. A background function rebuilds the system's public GeoJSON file and recomputestotalTrails,openTrails,cautionTrails,closedTrails,totalDistance,openDistanceand per-activity totals, usually within a few seconds. The response to your write already reflects the new document; the public map catches up shortly after. See Webhooks and automation. - Notifications.
notify: trueon a new update sends email and SMS to every subscriber of that trail system. There is no preview or undo. Keepnotifyfalse for routine condition posts. - Attribution.
createdBy/updatedByrecord the account that owns the key, and updates created here carrysource: "api". - Trash. Deleted trails and points go to
trashedTrails/trashedPointsand can be restored by a manager in the web app. Deleted updates are gone.
#Not yet available
Adding activities to a trail, uploading trail or point images, managing trustees, and rate limiting are not implemented in v1. Use the web app for those.