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 manager

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

CredentialScopesCan manage keysTypical use
API keyAs set on the key (read, or read + write)NoScripts, cron jobs, the MCP server
Firebase ID tokenAlways read + writeYesThe 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:

RoleHow you get itCan
AdministratorYou are the system's ownerId; or permissions[yourUid] === "Administrator"; or your account is a TrailHUB superAdminEverything below, plus PATCH /trail-systems/:id
Trustee (or another named role in permissions)Listed in the system's users[] or permissions mapRead 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 form new Date() parses in requests.

  • Coordinates are { "lat": 42.41, "lng": -79.31 } objects in responses. Point endpoints accept lat/lng (or latitude/longitude). GeoJSON geometry uses [lng, lat] as the spec requires.

  • PATCH changes only the fields you send. Sending no editable field returns 400 no_changes.

  • IDs are opaque strings. A trail system's ID is in its TrailHUB URL.

  • Successful responses: 200 with the resource, 201 for creations, 202 for 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

HTTPcodeMeaning
400missing_fieldA required field is absent
400invalid_fieldWrong type, out of range, or not in the allowed list
400invalid_dateCould not parse a date string
400invalid_geometryTrail geometry is not a usable LineString/MultiLineString
400invalid_jsonBody is not valid JSON
400no_changesPATCH body contained no editable fields
400unknown_activityTried to change an activity the trail does not have
400unknown_typeUpdate has a type the API cannot edit
400too_many_keysAlready 25 active keys
400missing_trail_systemNo trail system id in the path
401missing_credentialsNo Authorization / X-API-Key header
401invalid_api_keyKey not recognized
401revoked_api_keyKey was revoked
401expired_api_keyKey is past its expiry
401invalid_tokenID token invalid or expired
403id_token_required/keys called with an API key
403insufficient_scopeWrite attempted with a read-only key
403key_restrictedKey is limited to other trail systems
403forbiddenYou do not manage this trail system
403admin_requiredAdministrator role needed
404trail_system_not_found, trail_not_found, point_not_found, update_not_found, key_not_foundResource missing (or trail ids not in this system)
404not_foundNo such route
500internal_errorUnexpected server error

#Enumerations

NameValues
Trail statusOpen, Caution, Closed, None (None = derive from activities)
Point statusOpen, Caution, Closed
difficultycircle, square, diamond, doubleDiamond, terrainPark, notRated
Trail typeStandard, Lift
lineStyleSolid, Dashed
markerClassbar, 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 typenotice, surfaceConditions, snowReport
Notice severityinfo, warning, danger
Surface conditionscompacted, 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 languageen, fr
tempMeasurementfahrenheit, celsius
Key scopesread, write

GET /api/v1 returns these lists as JSON under enums.

#Endpoint summary

MethodPathAuthScope
GET/none—
GET/update-typesnone—
GET/meanyread
GET/keysID token—
POST/keysID token—
DELETE/keys/:idID token—
GET/trail-systemsanyread
GET/trail-systems/:idanyread
PATCH/trail-systems/:idAdministratorwrite
POST/trail-systems/:id/compileanywrite
GET/trail-systems/:id/weatheranyread
GET/trail-systems/:id/trailsanyread
POST/trail-systems/:id/trailsanywrite
POST/trail-systems/:id/trails/statusanywrite
GET/trails/:idanyread
PATCH/trails/:idanywrite
DELETE/trails/:idanywrite
GET/trail-systems/:id/pointsanyread
POST/trail-systems/:id/pointsanywrite
GET/points/:idanyread
PATCH/points/:idanywrite
DELETE/points/:idanywrite
GET/trail-systems/:id/updatesanyread
POST/trail-systems/:id/updatesanywrite
GET/updates/:idanyread
PATCH/updates/:idanywrite
DELETE/updates/:idanywrite

"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

FieldTypeRequiredRules
namestringyes1–100 characters
scopesstring[]noSubset of read, write; default both
trailSystemIdsstring[]noEach must be a system you manage
expiresInDaysnumberno1–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:

FieldTypeNotes
name, description, phoneNumber, websitestringPublic details
languagestringen / fr
depthMeasurement, lengthMeasurement, tempMeasurementstringUnits (inches/centimeters, miles/kilometers, fahrenheit/celsius)
inactive, hideName, promoteTrailHUBboolean
geolocation{lat,lng}
totalTrails, openTrails, cautionTrails, closedTrailsnumberRecomputed on compile; exclude lifts and hidden trails
totalDistance, openDistancenumberKilometres
activitiesarrayPer-activity totals
updatesobjectActive system-level updates keyed by type (notice, snowReport)
weatherobjectHourly snapshot (paid tiers) or absent
subscriptionstringPlan key
createdOn, updatedOn, updatedBy, ownerId

#PATCH /trail-systems/:id — Administrator only

FieldTypeRules
name, description, phoneNumber, website, depthMeasurement, lengthMeasurementstring≤ 2000 chars. website gets http:// prefixed if it lacks a scheme
languagestringen or fr
tempMeasurementstringfahrenheit or celsius
inactive, hideName, promoteTrailHUBbooleaninactive: 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

FieldTypeNotes
idstring
trailSystemIdstring
namestring≤ 200 chars
descriptionstring≤ 5000 chars
statusenumOpen, Caution, Closed, None
difficultyenumdefault notRated
typeenumStandard (default) or Lift
lineStyleenumSolid (default) or Dashed
hidden, oneWay, oneWayReversedbooleandefault false
ordernumberLower first. New trails get -(number of existing trails) so they sort to the top
categoriesstring[]
activitiesarray{ value, label, iconClass, status, difficulty? } per activity
distancenumberKilometres, computed from geometry
geolocation{lat,lng}First vertex
geoJsonobjectParsed 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

FieldRequiredNotes
nameyes≤ 200 chars
geometryyesGeoJSON LineString or MultiLineString with [lng, lat] coordinates
status, difficulty, description, type, lineStyle, hidden, oneWay, oneWayReversed, order, categoriesnoAs 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.

FieldNotes
statusRequired; trail status enum
trailIdsArray of trail ids in this system, or
alltrue 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

FieldTypeNotes
id, trailSystemIdstring
namestring≤ 200 chars
descriptionstring
markerClassenumIcon / type
statusenumOpen (default), Caution, Closed
hiddenbooleandefault false
waitTimenumberMinutes, integer ≥ 0; for wait-time points
webCamUrlstring≤ 2000 chars; for web-cam points
diningOptionsboolean/objectSet 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:

FieldRequiredNotes
typeyesnotice, surfaceConditions, snowReport
expirationDatenoISO date in the future; or
expiresInHoursno0 < h ≤ 8760; default 24 when neither is given
notifynotrue sends email/SMS to every subscriber of the trail system; default false
trailIds / allTrailsrequired for surfaceConditionsTrail ids in this system, or allTrails: true. Optional for notice (relates the notice to trails); ignored otherwise

Type-specific fields:

notice

FieldRequiredNotes
descriptionyesThe notice text
severitynoinfo (default), warning, danger
linkUrlno"More info" link

surfaceConditions

FieldRequiredNotes
conditionsyesNon-empty array from the surface-conditions enum

snowReport (all optional; amounts in the system's depth unit)

FieldType
baseDepth, twentyFourHours, fourtyEightHours, sevenDays, seasonTotal, lastSnowAmount, upperElevationDepthnumber
lastSnowDate, lastSnowMakingDatedate
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 again

With 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 recomputes totalTrails, openTrails, cautionTrails, closedTrails, totalDistance, openDistance and 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: true on a new update sends email and SMS to every subscriber of that trail system. There is no preview or undo. Keep notify false for routine condition posts.
  • Attribution. createdBy / updatedBy record the account that owns the key, and updates created here carry source: "api".
  • Trash. Deleted trails and points go to trashedTrails / trashedPoints and 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.