Back to writing

Backend infrastructure / provider normalization / SDKs / 2026

One mountain backend, fourteen data providers

PeakHut is the backend behind my outdoor apps. It connects to Météo-France, SLF, IGN, Copernicus, GLIMS, and other sources, then returns one consistent response through Swift and TypeScript SDKs.

The hard part is getting a dozen mountain data sources to agree. Their field names, units, languages, update schedules, and failure modes are all different. The app still needs one dependable answer.

Mountain apps are unforgiving about it. Someone taps a point on the map and wants to know: what am I standing in, what's the latest avalanche bulletin, how steep is this, are there alerts, where's the nearest hut, what could go wrong here. Those answers live in completely different places: national weather agencies, avalanche services, terrain datasets, public route databases, glacier catalogs, local condition reports, plus a few hazard layers I generate myself.

PeakHut handles that work in a Bun backend with provider adapters, normalized models, Postgres, background refresh jobs, protected routes, raw passthrough, Slab-compatible migration endpoints, and first-party Swift and TypeScript SDKs. Traverse iOS and Traverse Web use it today. It is single-tenant and only serves my own apps.

I built PeakHut to learn what it really takes to make an API-dependent product hold up: how integrations behave when a provider is slow, unavailable, or changes shape; where caching helps; and how much work sits between an upstream response and a feature I can rely on. It's still a small, single-tenant system, and that constraint is part of the exercise.

P
PeakHut Mountain intelligence
⌘ K
Mer de Glace / Chamonix

Mer de Glace context

45.9237, 6.8694 · Mont Blanc massif
Area Mont Blanc massif
Elevation 2,110 m · IGN
Bulletin Risk 3 · considerable
Hazards Crevasse + rockfall
Details
Open route

The same click, traced through the system

The map shows what the user sees. The trace follows that tap through the proxy, backend, cache, and provider adapters.

InputMap tap
Endpoint/v1/locations/summary
ResultOne normalized payload
FallbackLast good copy + fetch time
1
Client
User taps the map to view location context.
tap
2
Map runtime
Renders the map and captures the interaction.
4 ms
3
App backend proxy
Validates the request and forwards it with a scoped key.
9 ms
4
PeakHut API
Aggregates and normalizes data across providers via /v1/locations/summary.
7 ms
5
Cache
Normalize
Normalized response
Serves a cached, normalized payload for fast subsequent reads.
hit
6
Providers
Fetches the latest data from multiple upstream sources.
ready
7
Response
Returns normalized data and map-ready overlays to the client.
200 OK
Upstream sources
OpenStreetMapOSM IGNFR Swisstopo / SLFCH Météo-FranceFR GLIMSGlobal
200 OK normalized response: elevation + area + bulletin + hazards + GeoJSON overlays + provenance · 28 ms total
A map tap followed through rendering, the trusted proxy, PeakHut, its cache, and the provider adapters.
System boundary
Apps get product-shaped mountain data. Providers stay behind the backend.
14 Provider IDs in the platform contract
17 Dataset capability records
2 First-party client SDKs
01 Upstream providers

Meteo-France, SLF, IGN, Copernicus DEM, Camptocamp, GLIMS, Georisques, hut data, condition feeds, and generated hazard artifacts.

02 Adapters

Provider-specific fetching, parsing, attribution, raw passthrough, cache behavior, and failure handling live in the backend.

03 Normalization

Shared records for bulletins, alerts, observations, elevation, routes, hazards, huts, overlays, and provenance.

04 Protected API

Scoped API keys, rate limits, internal cron secrets, OpenAPI output, health state, and background refresh runs.

05 Client SDKs

Swift and TypeScript clients expose app flows without duplicating provider logic or shipping upstream credentials.

Clients render maps, routes, and interaction state. PeakHut handles provider logic, freshness, auth, normalization, and shared contracts.

The provider problem

Météo-France uses French fields and a 1-to-5 avalanche scale. SLF covers Switzerland. IGN provides metre-scale French elevation, while Copernicus trades detail for global coverage. Some sources return clean JSON, some need careful feed parsing, and some are tiles I generate and publish to object storage. Each source has its own refresh schedule and failure mode.

I put those rules in one capability registry. It records which provider covers each dataset, where it applies, how fresh it needs to be, and whether the raw payload is available. The current registry covers fourteen providers in roughly eighteen entries.

A resolver chooses the provider by geography. Points in France use IGN elevation, with Copernicus as the fallback outside its coverage. Avalanche bulletins switch between Météo-France and SLF at the border. The rule lives in one backend file. Here is a trimmed version:

ts src/core/providers.ts geography → provider
// one place decides which provider answers, by geography
export function getProviderPriority(dataset, lat, lon) {
  if (dataset === 'terrain_elevation') {
    // IGN is metre-grade but France-only; Copernicus covers the rest
    if (lat >= 41 && lat <= 51.5 && lon >= -5.5 && lon <= 9.8)
      return ['ign', 'copernicus-dem'];
    return ['copernicus-dem'];
  }

  if (dataset === 'avalanche_bulletins') {
    if (inSwissAlps(lat, lon))  return ['swiss-avalanche'];
    if (inFrenchAlps(lat, lon)) return ['meteofrance'];
    return ['meteofrance', 'swiss-avalanche'];
  }
  // ...one branch per dataset
}
The real resolver, lightly trimmed. The first provider that returns a result wins, and the response records which provider answered.
Normalization architecture
Provider mess goes in. Product contracts come out.

Provider inputs

Meteo-FranceBRA bulletins, vigilance, observations, raw weather proxies.
SLFSwiss avalanche warning regions and bulletin data.
Terrain and hazardsIGN, Copernicus DEM, GLIMS, Georisques, snow and crevasse artifacts.
Routes and hutsCamptocamp discovery plus enriched mountain hut records.

PeakHut core

Capability registryWhich provider supports which dataset, geography, freshness, and raw access.
Provider resolverGeography-aware priority, fallback, and explicit rationale.
Adapters + normalizersProvider-specific parsing becomes shared domain records.
Postgres cacheFreshness state, stale fallback, refresh jobs, and provenance.

App contracts

Point summary/v1/locations/summary for map tap sheets.
Route conditions/v1/routes/conditions for GPX and planned routes.
Hazard layersGlaciers, snow cover, crevasse, rockfall, conditions, and overlays.
SDKsSwift and TypeScript clients mirror the backend contract.
Provider normalization is centralized in PeakHut so every app receives the same area, route, terrain, hazard, and provenance contracts.
Provider routing
The backend treats providers as capabilities, not one-off integrations.
Weather and avalanche Meteo-France + Swiss Avalanche

BRA bulletins, vigilance alerts, nearest observations, Swiss warning regions, and Slab-compatible bulletin migration routes.

avalanche_bulletins alerts observations
Terrain IGN + Copernicus DEM

Provider-aware elevation lookup, LiDAR DEM TileJSON catalogs, and terrain overlay tile generation for existing map clients.

terrain_elevation fallback
Routes and huts Camptocamp + hut records

Route discovery, route detail, waypoints, attribution, searchable enriched hut records, and R2-backed photo URLs.

route_discovery mountain_huts
Hazards GLIMS, Georisques, artifacts

Glacier outlines, velocity samples, snow cover, condition reports, rockfall events, and crevasse susceptibility layers.

route_hazards crevasse_hazards
PeakHut keeps the product API stable even while providers differ by country, dataset, latency, cacheability, and reliability.

This was new territory for me: defining product contracts I could extend as the apps changed, knowing some would eventually need a full migration, while building the infrastructure underneath them. As a designer, I was probably going too deep into software architecture. That was also where the fun was.

The normalized contract

For a map-tap sheet, the app calls /v1/locations/summary once. The response includes the point, elevation, surrounding areas, latest bulletin, nearest observation, alerts, and map-ready GeoJSON overlays.

Route analysis starts with a GPX file and returns conditions plus overlays. Narrow endpoints are still available when a screen only needs one layer.

Every record includes the provider, fetch time, normalization time, and resolver rationale. When an app shows something odd, that provenance usually tells me whether the source data is wrong or my mapping is wrong.

GET /v1/locations/summary 200 · application/json
{ "point": { "latitude": 45.9237, "longitude": 6.8694 }, "elevation": { "provider": "ign", "dataset": "terrain_elevation", "provenance": { "resolver": { "strategy": "provider-preferred", "rationale": "IGN preferred in France, Copernicus fallback elsewhere" } } }, "primaryArea": { "id": "mf:mont-blanc", "name": "Mont Blanc" }, "bulletin": { "overallRisk": 3, "riskLabel": "considerable" }, "observation": { "stationName": "nearest station" }, "alerts": [], "overlays": { "selectedPoint": "GeoJSON FeatureCollection", "areas": "GeoJSON FeatureCollection" }}
The exact payload changes per endpoint, but the shape holds: product-ready mountain context with the provider provenance attached so you always know where each piece came from.

The hard parts

Cache policy affects safety. Avalanche bulletins refresh every three hours and expire after six. Elevation is cached for a year. Less reliable hazard feeds get a one-hour cache; if a source is down, PeakHut serves the last good copy with its fetch time so the app can show its age. All of this data is planning context, never a go/no-go call.

Normalization is most of the work. French and Swiss bulletins use different fields and languages. Avalanche risk combines a 1-to-5 number with labels. Elevation arrives in UTM, RGF93, or WGS84. PeakHut renames fields, reprojects coordinates, and converts units before an app sees the response.

GLIMS, Georisques, Camptocamp, and the condition feeds have parser fixtures because their upstream shape can change without warning. The resolver has coverage tests too: a wrong bounding box can return the wrong country's avalanche bulletin.

Security and operational boundaries

Provider keys, tile secrets, cron secrets, and long-lived platform keys stay on the backend.

There are three zones. /health is open. The real endpoints (/openapi.json, /ops, /v1/*, /v2/*) need an x-api-key with a scope like platform.read or platform.route. The internal refresh jobs sit behind a separate x-cron-secret. Rate limiting runs per key and per IP, with the heavier tile traffic on its own budget.

I can change the provider, cache, or artifact behind a feature while the app keeps calling the same method.

Backend architecture
PeakHut is a service layer, not a bundle of client helpers.
Runtime Bun HTTP service

Route registration, CORS, validation with Zod, protected path checks, rate limiting, health state, and admin assets.

Bun TypeScript Zod
Persistence Postgres-backed refresh

Provider refresh runs normalize raw records, persist durable state, and expose stale-cache fallback for unreliable sources.

Postgres refresh jobs stale fallback
Operational API Health, OpenAPI, ops UI

Health reports configured app keys and provider capabilities. OpenAPI and the internal ops dashboard make the surface inspectable.

/health /openapi.json /ops
Migration layer Slab-compatible routes

Additive v1 and v2 routes let existing Slab and Traverse consumers move onto PeakHut without changing every app contract at once.

/v1/bulletins /v2/zones terrain tiles
Slab consumers keep using their existing routes while they move onto the normalized API in smaller steps.

SDKs as product infrastructure

The apps use a Swift package and a TypeScript client. Both stay thin: they mirror the response shape, expose named flows, and leave provider logic on the server.

The Swift SDK has async methods like locationSummaryWithOverlays(at:), searchMountainHuts, crevassePoint, parseGPX, routeConditions, and snowCoverTileJSON. The TypeScript SDK mirrors the same flows for web, React Native, SSR, and Node, with an injectable fetch for server environments and tests.

The SDKs make common calls one line long and keep the server contract visible. When that contract changes, the backend, OpenAPI, Swift models, and TypeScript models change together.

Client stack
The SDKs expose product flows while keeping provider logic centralized.
Native Apple PeakhutSDK Swift package

Codable models and async URLSession calls for iOS and Apple-platform clients. Built for map tap sheets, route analysis, hazard layers, huts, and GPX flows.

Swift Codable async/await
Web and server @peakhut/sdk-web

A fetch-based TypeScript client for browser apps, React Native, SSR, and Node backends. It keeps the JSON contract visible and typed.

TypeScript SSR React Native
Recommended security App backend proxy

Production apps should usually call their own backend, which injects the PeakHut API key server-side. Internal tools can call PeakHut directly.

x-api-key off client scoped keys
Documentation Feature-to-endpoint API maps

The iOS and web maps translate product features into SDK methods and HTTP endpoints so new app work starts from the right contract.

API_MAP.md OpenAPI
The backend defines the contract; the SDKs provide typed wrappers for it.

What it's actually running

I built PeakHut after copying the same provider clients between several outdoor apps. The shared plumbing now lives in one backend and each app keeps its own interface.

  • Traverse iOS uses PeakHut for map overlays, route planning, avalanche and hazard layers, snow cover, LiDAR terrain, glacier data, and huts. A map tap is one call: locationSummaryWithOverlays(at:).
  • Traverse Web uses its own backend proxy so provider keys never reach the browser. It requests viewport-scoped glacier outlines, LiDAR catalogs, snow cover, and map layers.
  • Slab migration uses compatibility routes for French bulletins, global avalanche zones, terrain overlays, weather profiles, and alerts. Existing consumers can move over gradually.
  • Outpost uses the shared mountain-hut records, search, route context, and R2-backed photos through PeakHut.
  • Anything I build next (iOS, web, React Native, server-side) gets the same contract on day one, with rendering and caching left to the app.

What's still rough

The adapters, normalized contracts, protected routes, background jobs, raw diagnostics, migration endpoints, and SDKs are working. The next job is completing the OpenAPI definition so it can generate the Swift and TypeScript models. Those models are still mirrored by hand and can drift.

Long refresh jobs also need a supervised queue, provider health needs real scoring, and rate limiting needs to move out of process memory. PeakHut is single-region and single-tenant today, and the OpenAPI coverage is partial.

I haven't tested any of this under massive traffic. Across my apps and TestFlight builds, roughly 300 to 400 people use the products, which isn't enough load to properly stress the infrastructure or show me where it breaks. The next lesson may just be Railway, the hosting provider, sending me a bill twenty times larger.