Unofficial Go client + REST server + webapp for the Spoke/Circuit routing app
  • Go 38.4%
  • Python 35%
  • JavaScript 16.3%
  • CSS 7%
  • HTML 3.3%
Find a file
2026-04-23 02:07:28 -07:00
cmd/spoke initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
docs initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
scripts initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
spoke initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
web initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
.gitignore initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
go.mod initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00
README.md initial commit: Go client, REST server, Leaflet webapp, docs 2026-04-23 02:07:28 -07:00

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 creatingoptimizingoptimized (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)

  1. -password flag
  2. SPOKE_PASSWORD environment variable
  3. SPOKE_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.mdEndpoint 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.json stores 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 /optimize call creates a new route in Firestore. Use DELETE /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_delivery profile, min completion_time).
  • Webapp uses Leaflet + OpenStreetMap tiles.

License

MIT — do whatever, attribution appreciated, no warranty.