A serverless application that accepts incoming SMS messages containing latitude/longitude coordinates, looks up the corresponding Avalanche Canada forecast region, and replies with a template-formatted summary of current avalanche conditions. Includes an admin web UI for monitoring.
- User sends SMS to a Twilio phone number with a message containing coordinates (e.g.
51.0447 -115.0632) - Twilio forwards the message to the server via webhook (HTTP POST)
- Server parses lat/long from the message body
- Server queries Avalanche Canada API to determine which forecast region contains the coordinates
- Server fetches the current forecast bulletin for that region
- Server formats the forecast into an SMS-friendly summary using a template
- Server replies via Twilio with the summary SMS
- Request metadata is logged in-memory for the admin dashboard
| Component | Choice |
|---|---|
| Runtime | Node.js with TypeScript |
| HTTP Framework | Hono |
| SMS Provider | Twilio |
| Forecast Source | Avalanche Canada public API |
| Summarization | Template-based (no LLM) |
| Deployment Target | Cloudflare Workers or AWS Lambda |
| Storage | In-memory (no database) |
| Caching | None |
Twilio webhook endpoint for incoming SMS messages.
Request: Twilio webhook form-encoded POST body containing Body, From, To, MessageSid, etc.
Behavior:
- Validate the request is from Twilio (signature verification)
- Parse coordinates from
Bodyfield - Look up forecast region and fetch bulletin
- Format response using template
- Return TwiML XML response with reply message
Response: Content-Type: text/xml — TwiML <Response><Message>...</Message></Response>
Error cases:
- Unparseable coordinates → reply with usage instructions
- Coordinates outside any forecast region → reply stating no forecast region found
- Avalanche Canada API unavailable → reply with error message asking to try later
Serves the admin dashboard HTML page.
Returns live health check results for all dependent services.
Response:
{
"overall": "ok",
"checked_at": "2026-02-23T10:30:00Z",
"services": [
{
"name": "Avalanche Canada API",
"status": "ok",
"latency_ms": 142,
"detail": "Responding normally"
},
{
"name": "Twilio",
"status": "ok",
"latency_ms": 87,
"detail": "Authenticated and responding"
}
]
}overall is "ok" if all services are ok, "degraded" if any are degraded, "down" if any are down.
Returns 200 OK with { "status": "ok" } for uptime monitoring.
The server must parse coordinates from free-text SMS messages. Supported input formats:
| Format | Example |
|---|---|
| Decimal degrees | 51.0447 -115.0632 |
| Comma-separated | 51.0447, -115.0632 |
| With cardinal directions | 51.0447N 115.0632W |
| Signed (negative for W/S) | 51.0447 -115.0632 |
When users share their location from a phone, the SMS body contains a URL rather than raw coordinates. The server must detect and extract coordinates from these URL formats.
Apple Maps (iOS "Share My Location" / "Send My Current Location"):
| URL Pattern | Example |
|---|---|
maps.apple.com/?ll={lat},{lng} |
https://maps.apple.com/?ll=51.0447,-115.0632&q=... |
maps.apple.com/?q={lat},{lng} |
https://maps.apple.com/?q=51.0447,-115.0632 |
maps.apple.com with address param |
Extract ll param if present, else geocode not supported |
Parse the ll query parameter first (takes precedence). Fall back to q if ll is absent and q contains a coordinate pair.
Google Maps (Android share via Google Maps / Google Messages):
| URL Pattern | Example |
|---|---|
google.com/maps with @{lat},{lng} in path |
https://www.google.com/maps/@51.0447,-115.0632,15z |
google.com/maps with q={lat},{lng} |
https://www.google.com/maps?q=51.0447,-115.0632 |
maps.app.goo.gl/{id} (shortened) |
https://maps.app.goo.gl/abc123 |
goo.gl/maps/{id} (legacy shortened) |
https://goo.gl/maps/abc123 |
For shortened URLs (maps.app.goo.gl, goo.gl/maps): the server must follow the redirect (HTTP HEAD or GET) to resolve the full URL, then extract coordinates from the expanded URL using the patterns above.
- Check if the message body contains a URL (starts with
httpor contains a known maps domain) - If URL found, match against known map link patterns and extract coordinates
- If no URL, attempt to parse raw coordinate formats
- Validate extracted coordinates against Canadian bounds
Validation rules:
- Latitude must be between 40.0 and 70.0 (reasonable for Canada)
- Longitude must be between -145.0 and -50.0 (reasonable for Canada)
- If coordinates are outside Canada's bounds, return an error message
Endpoint: GET https://api.avalanche.ca/forecasts/en/areas
Returns GeoJSON FeatureCollection of all forecast regions with polygon boundaries. Use point-in-polygon testing to determine which region contains the user's coordinates.
The region polygons should be fetched on server startup (or first request) and held in memory for point-in-polygon lookups. Re-fetch periodically (e.g. daily) to pick up boundary changes.
Endpoint: GET https://api.avalanche.ca/forecasts/en/products/point?lat={lat}&long={lng}
Returns the forecast bulletin for the region containing the given point. This is the only endpoint needed — it handles region lookup internally. Note the query parameter is long, not lng.
The response is a single JSON object with this structure:
{
report: {
title: string // Region name (e.g. "Waterton")
dateIssued: string // ISO date
validUntil: string // ISO date
highlights: string // HTML summary text
dangerRatings: [{
date: { value: string, display: string },
ratings: {
alp: { display: "Alpine", rating: { value: string, display: string } },
tln: { display: "Treeline", rating: { value: string, display: string } },
btl: { display: "Below Treeline", rating: { value: string, display: string } }
}
}],
problems: [{
type: { value: string, display: string }, // e.g. "Wind slab"
...
}]
}
}
Danger rating value field uses: "low", "moderate", "considerable", "high", "extreme", "noRating".
The reply must fit within a single SMS: 160 characters maximum (GSM-7 encoding). This keeps costs to one segment per reply and forces a concise, scannable format.
{region_name}
{date_issued}
Alp:{alpine} TL:{treeline} BT:{below_treeline}
{problem_list}
avalanche.ca
Example (137 chars):
Kananaskis
Feb 23
Alp:Considerable TL:Moderate BT:Low
Storm Slabs, Wind Slabs
avalanche.ca
- Use abbreviated elevation labels:
Alp,TL,BT - Use short date format:
Mon DD(e.g.Feb 23) - Omit "Avalanche Forecast" header text
- Omit highlights/summary text
- If problem list would push over 160 chars, truncate to first N problems that fit
- If the region name is very long, truncate with ellipsis to keep total under 160
Map numeric/enum ratings to display strings:
1→Low2→Moderate3→Considerable4→High5→Extreme- No rating →
No Rating
A single-page HTML status dashboard served at /admin. Uses inline CSS and vanilla JS (no build step, no framework). Fetches data from /admin/status on load and via polling every 30 seconds.
- Overall status banner — green (all ok), yellow (degraded), red (down)
- Service cards — one per dependency, showing name, status badge, detail message, and response latency
- Refresh button — manual re-check
- Last checked timestamp
Each check runs live on every /admin/status request:
| Service | Check Method | OK Condition |
|---|---|---|
| Avalanche Canada API | GET /forecasts/en/products/point with a test coordinate |
HTTP 200 or 404 (API up, just no region at test point) |
| Twilio | GET /2010-04-01/Accounts/{sid}.json with Basic auth |
HTTP 200 (credentials valid) |
The Twilio phone number's incoming message webhook must be configured to POST to https://{host}/sms/incoming.
All incoming requests to /sms/incoming must be validated using Twilio's request signature verification to prevent spoofing. The X-Twilio-Signature header is validated against the auth token.
In development/testing, signature validation can be disabled via environment variable.
| Variable | Required | Description |
|---|---|---|
TWILIO_ACCOUNT_SID |
Yes | Twilio account SID |
TWILIO_AUTH_TOKEN |
Yes | Twilio auth token for signature validation |
TWILIO_PHONE_NUMBER |
No | The Twilio phone number (for display purposes) |
ADMIN_ENABLED |
No | Set to false to disable admin routes (default: true) |
VALIDATE_TWILIO_SIG |
No | Set to false to skip signature validation (dev only) |
All errors must be caught and result in a friendly SMS reply. The user should never receive a raw error or no response.
| Scenario | SMS Reply |
|---|---|
| Unparseable message | "Send your coordinates as: lat, lng (e.g. 51.04, -115.06)" |
| Coordinates outside Canada | "Those coordinates are outside Canadian avalanche forecast areas." |
| No forecast region for location | "No avalanche forecast region covers that location." |
| Avalanche.ca API error | "Unable to fetch forecast right now. Please try again later." |
| No active forecast for region | "No active forecast for {region}. Check avalanche.ca for updates." |
/
├── src/
│ ├── index.ts # Hono app setup, route registration
│ ├── routes/
│ │ ├── sms.ts # POST /sms/incoming handler
│ │ ├── admin.ts # GET /admin, GET /admin/status
│ │ └── health.ts # GET /health
│ ├── services/
│ │ ├── avalanche-ca.ts # Avalanche Canada API client
│ │ ├── twilio.ts # Twilio signature validation, TwiML helpers
│ │ └── status.ts # Live health checks for dependent services
│ ├── lib/
│ │ ├── parse-coordinates.ts # Coordinate parsing from SMS text
│ │ └── format-forecast.ts # Template formatting for SMS reply
│ └── templates/
│ └── admin.html # Admin dashboard HTML
├── test/
│ ├── parse-coordinates.test.ts
│ ├── format-forecast.test.ts
│ ├── sms.test.ts
│ └── avalanche-ca.test.ts
├── wrangler.toml # Cloudflare Workers config
├── tsconfig.json
├── package.json
├── CLAUDE.md
├── README.md
└── SPECIFICATION.md
- Unit tests for coordinate parsing (all supported formats, edge cases, invalid input)
- Unit tests for forecast template formatting
- Integration tests for the SMS webhook handler using mock Avalanche.ca responses
- Test runner: Vitest
- User authentication (the SMS endpoint is secured by Twilio signature verification only)
- Persistent storage or analytics
- Multi-language support (English only, matching avalanche.ca/en)
- LLM-based summarization
- Rate limiting (rely on Twilio's built-in protections)
- Forecast caching