- Go 38.4%
- Python 35%
- JavaScript 16.3%
- CSS 7%
- HTML 3.3%
| cmd/spoke | ||
| docs | ||
| scripts | ||
| spoke | ||
| web | ||
| .gitignore | ||
| go.mod | ||
| README.md | ||
spoke-api
Unofficial reverse-engineered client + REST server + optional webapp for the Spoke / Circuit delivery routing app.
What it does: give it a list of addresses, it returns the optimized driving order. Auth, geocoding, route creation, and optimization all handled transparently — no app install, no iOS device needed.
Disclaimer
This project is an unofficial client. It was built by observing the iOS app's network traffic on a personal account. Use it only:
- on accounts you own
- for learning, research, or personal tooling
- in accordance with Spoke/Circuit's terms of service
The author has no affiliation with Underwood Apps or Circuit. No guarantees — the upstream API can change at any time.
How it works
Spoke's iOS app uses:
- Firebase Identity Platform for auth (email/password, project
circuit-prod) - Firebase Cloud Firestore for all route / stop / user state (REST API, no gRPC needed)
api.spoke.com/rest/*for address geocoding and triggering optimization- GraphHopper VRP as the optimizer (embedded in Firestore optimization docs)
Optimization is asynchronous: POST /optimize returns an optimizationId
immediately, then the backend reads stops from Firestore, calls GraphHopper,
and writes results back onto the individual stop documents (arrivalTime,
distanceMeters, polyLineString, etc.). Poll the route doc's
state.optimization field for completion; it flips creating → optimizing
→ optimized (typical: <2s for 3 stops).
Full protocol details live in docs/api.md.
Quick start
1. Build or run
# Run directly (requires Go 1.22+):
go run ./cmd/spoke -password hunter2
# Or build a binary:
go build -o spoke ./cmd/spoke
./spoke -password hunter2
On first boot the server generates a random email, signs up a new Firebase
account with the password you supplied, and saves everything to creds.json
(mode 0600, same directory). On subsequent boots it loads that file and
automatically refreshes the Firebase ID token.
2. Password sources (first match wins)
-passwordflagSPOKE_PASSWORDenvironment variableSPOKE_PASSWORD=…line in.env(same dir)
3. Try it
Open http://localhost:8080/ — the bundled webapp gives you a form + Leaflet
map. Enter a start location (type/click/pin-drop/address), drop addresses into
the textarea, hit Optimize.
Or hit the REST API directly:
curl -X POST localhost:8080/optimize \
-H 'Content-Type: application/json' \
-d '{
"addresses": ["Stanford Shopping Center", "SFO", "Palo Alto High School"],
"current": {"latitude": 37.4387, "longitude": -122.2231}
}'
CLI flags
-listen :8080 HTTP listen address
-password hunter2 Firebase password (first boot only; then cached)
-creds creds.json Path to credentials file
-env .env Path to .env file
-optimize-timeout 60s How long to wait for optimization completion
-web Serve the webapp (default true)
-web-dir web Directory the webapp lives in
Run API-only (no webapp) with -web=false — useful for a headless
integration.
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/health |
{ok, email, userId} |
POST |
/addresses/resolve |
{query, current} → geocoded address |
POST |
/optimize |
{addresses[], current, title?, roundTrip?} → ordered stops |
GET |
/routes |
List saved routes (newest first) |
GET |
/routes/{id} |
One route with its optimized stops |
DELETE |
/routes/{id} |
Delete route + cascade stops + optimizations |
Full request/response shapes in docs/api.md → Endpoint Quick Reference.
Project layout
spoke-api/
├── cmd/spoke/main.go # HTTP server + CLI flags
├── spoke/ # Go client library
│ ├── auth.go # Firebase signup / signin / refresh / creds
│ ├── client.go # HTTP client + header injection + token mgmt
│ ├── addresses.go # /addresses/v2/search, geocodeWithPreferences
│ ├── firestore.go # Firestore REST encode/decode, GET/PATCH/DELETE
│ ├── routes.go # route + stop builders, optimize, poll
│ └── types.go # shared types
├── web/ # Optional Leaflet webapp (vanilla JS)
│ ├── index.html
│ ├── app.js
│ └── style.css
├── scripts/ # Python helper scripts (reverse-engineering)
│ ├── parse_capture.py # mitmproxy capture → JSON
│ ├── explore_firestore.py # probe Firestore paths (read-only)
│ └── e2e_optimize.py # Python port of the full pipeline
└── docs/api.md # Full API reference + Firestore data model
Using the Go package
import "spoke-api/spoke"
c, _ := spoke.New("creds.json")
if c.Credentials().Email == "" {
c.Bootstrap(ctx, "hunter2")
}
routeID, stops, err := c.OptimizeAddresses(ctx,
[]string{"SFO", "Stanford Shopping Center"},
spoke.LatLng{Latitude: 37.4387, Longitude: -122.2231},
"my route", true)
Security / caveats
creds.jsonstores the password in plaintext. It's designed for local single-user development, not secret storage. Rotate the password on the account (or burn the account) if the file leaks. Gitignored by default.- The Firebase Web API key in the code is not secret. It's a client identifier (visible in any iOS IPA) — not the same as a service-account key. Firebase relies on Firestore security rules, not key secrecy.
- No cert pinning on our side. Firestore REST uses standard TLS.
- Every
/optimizecall creates a new route in Firestore. UseDELETE /routes/{id}or the webapp's History panel to clean up test data. - Rate limits unknown. Be gentle.
Credits
- Protocol mapped from mitmproxy captures of Spoke iOS
3.20.28 (41122). - Optimizer is GraphHopper VRP (
car_deliveryprofile,min completion_time). - Webapp uses Leaflet + OpenStreetMap tiles.
License
MIT — do whatever, attribution appreciated, no warranty.