Swarm-Reitti Bridge
kstack:
book: Centerpoint Home Lab
chapter: Documents & Organization
page: Swarm-Reitti Bridge
tags: [swarm-reitti-bridge, foursquare, reitti, location, custom-built, node]
Overview
Swarm-Reitti Bridge (v1.1.0) is a custom-built Node.js service that automatically
syncs Foursquare/Swarm check-ins into Reitti.two downstream services:
It was built specifically for this homelab as a bridge between the Foursquare API and
Reitti'sthese OwnTracks-compatibleself-hosted ingestion endpointservices — keeping all location history self-hosted.under local control.
The service runs at https://swarm.jeeves5454.ddns.net and is externally reachable so
the OAuth callback from Foursquare can complete.
This service was built in this homelab environment. Its source code lives at
/home/jeeves/docker/and the productionswarmreitti/code-server/projects/swarm-reitti-FINAL-POLLING/bridge/builddeployment at/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/. The container image (swarm-reitti-) is built locally viabridge-swarm-bridgedocker compose buildand is not published to any registry.
Background: Why Polling Mode?
This bridge was originally designed to use Foursquare push notifications —
Foursquare would POST to the bridge's /push endpoint on every check-in,
enabling near-instant syncing. That architecture worked correctly.
In late 2024 / early 2025, Foursquare changed their API pricing and moved push notifications behind a paid credit system. Attempting to subscribe to push notifications returns:
{ "errorType": "credits_exhausted", "code": 402 }
The bridge was updated to polling mode as the solution. Instead of waiting for Foursquare to push, it actively polls the Foursquare API every 15 minutes for new check-ins.
| Aspect | Push (legacy, broken) | Polling (current) |
|---|---|---|
| Trigger | Foursquare → Bridge (instant) | Bridge → Foursquare (timer) |
| Latency | < 1 second | Up to 15 minutes |
| API credits required | Yes (paid) | No (free tier) |
| API calls/day | ~0 idle | 96 (10% of free quota) |
| Cost | Requires payment | $0.00 |
The 15-minute maximum latency is entirely acceptable for personal location history tracking.
How It Works
High-Level Flow
User checks in on Swarm
↓
Foursquare API stores check-in
↓
Bridge polls every 15 minutes (or immediately on container start)
GET /v2/users/self/checkins?oauth_token=...
↓
Bridge identifies new check-ins (deduplication via lastCheckinIds)
↓
Bridge┌────────────────────────────────────────────────────────┐
converts│ For each new check-in (oldest-first): │
│ │
│ 1. Convert to OwnTracks format ↓│
│ POST to Reitti OwnTracks ingest API │
│ Authorization: Bearer <REITTI_API_TOKEN> │
│ ↓ │
│ Check-in appears on Reitti map │
│ │
│ 2. (if AdventureLog configured) │
│ Find or create AL Category (Foursquare → emoji) │
│ Find or create AL Location (dedup by name + coords) │
│ Create AL Visit (dedup: one per location per day) │
│ Mark AL World Travel region as visited │
│ ↓ │
│ Location + Visit in AdventureLog │
│ Region marked on world map │
└────────────────────────────────────────────────────────┘
↓
State saved to disk (lastCheckinId, lastCheckinTimestamp)
AdventureLog failures are non-fatal — Reitti sync completes regardless.
Deduplication
The bridge tracks lastCheckinIds per user — a map of userId → most recently processed check-in ID. On each poll,poll:
lastCheckinIds[userId], the poll exits immediately (already up to date).
Gap fetch — if different, fetches all check-ins Startup Behaviour
On container start, the lastbridge knownimmediately IDpolls for any users already stored in
state.json — it does not wait for the first 15-minute interval. This ensures
check-ins that occurred while the container was down are forwardedprocessed towithin Reitti.seconds
Thisof state is persisted to disk
(/app/data/state.json) so it survives container restarts.restart.
State Persistence
Three state objects are saved to /app/data/state.json:
| Key | Contents |
|---|---|
userTokens |
OAuth access tokens per userId (from Foursquare) |
lastCheckinTimestamps |
Last seen check-in Unix timestamp per userId |
lastCheckinIds |
Last processed check-in ID per userId |
Writes are atomic (write to .tmp, then rename) to avoid corruption on crash.
Permission note:
state.jsonis owned by root (written inside the container). Usesudowhen editing on the host.
Data Mapping
The
Reitti bridge converts Foursquare check-in JSON to Reitti's (OwnTracks format:Format)
| OwnTracks Field | Source | Notes |
|---|---|---|
_type |
Hardcoded: "location" |
OwnTracks type identifier |
lat |
checkin.venue.location.lat |
Venue latitude |
lon |
checkin.venue.location.lng |
Venue longitude (lng → lon) |
tst |
checkin.createdAt |
Unix timestamp of check-in |
tid |
First 2 |
OwnTracks tracker ID |
acc |
Hardcoded: 10 |
Accuracy in metres |
alt |
Hardcoded: 0 |
Altitude (not available from Foursquare) |
batt |
Hardcoded: 100 |
Battery (not applicable) |
vel |
Hardcoded: 0 |
Velocity (not applicable) |
t |
Hardcoded: "c" |
OwnTracks trigger type: check-in |
desc |
checkin.venue.name (max 200 chars) |
Venue name |
addr |
Address + City + State + Country (max 300 chars) | Formatted address string |
The payload is sent as:
POST <REITTI_API_URL>
Authorization: Bearer <REITTI_API_TOKEN>
Content-Type: application/json
AdventureLog Integration
Category Mapping
adventurelog-categories.js); unknown categories fall back to 🌍
Secondary Foursquare categories become AdventureLog tags
Location Deduplication
Locations are matched by venue name (lowercase) + coordinates rounded to 2 decimal places
(≈1.1 km tolerance). If a match exists, it is reused without modification. If not, a new
location is created. Coordinates sent to AdventureLog are rounded to 6 decimal places
(AdventureLog enforces a 9-total-digit limit on lat/lng fields).
Visit Deduplication
One visit is created per location per local calendar day. The local date is derived using
the venue's timezone (resolved from lat/lng via geo-tz). A check-in shout and any
companion names (with array) are combined into the visit notes field.
World Travel (Region Marking)
After each visit is created, the bridge resolves the check-in's country and state/province
to an ISO 3166-2 region code via AdventureLog's GET /api/regions list, then POSTs to
/api/visitedregion. Already-visited regions are cached per poll cycle and skipped silently.
A small country name map handles known Foursquare → AdventureLog naming mismatches
(e.g. México → Mexico). Unresolvable regions are logged as warnings.
Note: Creating Locations and Visits via the API does not automatically update the World Travel world map — these are separate systems in AdventureLog. The bridge handles both independently.
OAuth Setup
Foursquare OAuth 2.0 is used to grant the bridge permission to read check-in data on behalf of the user.data.
This is a one-time setup that persists in the state file.
Initial Setup Steps
-
Visit the auth URL:
https://swarm.jeeves5454.ddns.net/auth -
The bridge redirects to Foursquare with the configured
client_idandredirect_uri. -
Authenticate with your Foursquare account and authorise the app.
-
Foursquare redirects back to
https://swarm.jeeves5454.ddns.net/callbackwith an authorisation code. -
The bridge exchanges the code for an access
tokentoken, saves it to state, andstores it in the state file. Pollingbeginsautomatically.polling immediately.
Important: Re-authenticating via
/authresets the sync baseline to the most recent check-in at that moment — older unsynced check-ins will be skipped. Only re-authenticate if starting fresh or after a token expiry. If you need to reprocess past check-ins, patchstate.jsondirectly (see Operations below).
Foursquare App Configuration
The Foursquare developer app must have these settings:
| Field | Value |
|---|---|
| Redirect URI | https://swarm.jeeves5454.ddns.net/callback |
| Push API URL | (not required |
If the OAuth flow breaks, re-authenticate by visiting /auth. The new token
replaces the stored one.
Access and Endpoints
External URL: https://swarm.jeeves5454.ddns.net
Certificate: Let's Encrypt (letsencrypt resolver)
Port: 3000 (internal)
Auth middleware: None — OAuth is handled internally
API Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Status dashboard |
GET |
/auth |
Start Foursquare OAuth flow (rate-limited: 10/15 min) |
GET |
/callback |
OAuth callback (Foursquare redirects here) |
GET |
/health
JSON health — ok, per-service status, last sync times
POST
/push
Legacy webhook endpoint (inactive in polling mode)
GET/health200 OKGET/statusThe / endpoint authhealthisreturns rate-limitedstructured (10JSON requestsincluding peran 15-minuteok: window)true/falsetofield
preventsuitable OAuthfor abuse.Uptime Kuma's JSON Query monitor:
{
"ok": true,
"status": "healthy",
"connectedUsers": 1,
"reitti": { "configured": true, "lastSuccessAt": "...", "lastSuccessVenue": "..." },
"adventurelog": { "configured": true, "lastSuccessAt": "...", "lastSuccessVenue": "..." },
"lastPollAt": "..."
}
Configuration
Environment Variables
| Variable | Value / Notes |
|---|---|
FOURSQUARE_CLIENT_ID |
REDACTED ( |
FOURSQUARE_CLIENT_SECRET |
REDACTED |
FOURSQUARE_REDIRECT_URI |
https://swarm.jeeves5454.ddns.net/callback |
REITTI_API_URL |
https://reitti.jeeves5454.ddns.net/api/v1/ingest/owntracks |
REITTI_API_TOKEN |
REDACTED (Reitti API |
PUSH_SECRET |
REDACTED ( |
ADVENTURELOG_API_URL
https://travel.jeeves5454.ddns.net
ADVENTURELOG_API_KEY
REDACTED (AdventureLog API key)
UPTIME_KUMA_PUSH_URL
REDACTED (Uptime Kuma push monitor URL, no query params)
POLLING_INTERVAL_MINUTES
15 (default)
PORT
3000
NODE_ENV
production
Omitting both ADVENTURELOG_API_URL and ADVENTURELOG_API_KEY disables AdventureLog
sync entirely. Omitting UPTIME_KUMA_PUSH_URL disables push heartbeats.
Traefik Labels
traefik.http.routers.swarm-bridge.rule: Host(`swarm.jeeves5454.ddns.net`)
traefik.http.routers.swarm-bridge.entrypoints: websecure
traefik.http.routers.swarm-bridge.tls.certresolver: letsencrypt
traefik.http.services.swarm-bridge.loadbalancer.server.port: 3000
Volumes / Bind Mounts
| Host Path | Container Path | Purpose |
|---|---|---|
/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/data |
/app/data |
State file (state.json) persisted across restarts |
Monitoring (Uptime Kuma)
Three complementary monitors are recommended:
swarm-reitti-bridge
Container crash / OOM kill
HTTP(s)
https://swarm.jeeves5454.ddns.net/health
App unresponsive
HTTP(s) JSON Query
https://swarm.jeeves5454.ddns.net/health → $.ok
Reitti/AL sync errors, no users
Push
Uptime Kuma push monitor URL
Poll loop stopped, token expired
The push monitor receives ?status=up after every successful poll and
?status=down&msg=<reason> on failure. Configure the heartbeat interval to
POLLING_INTERVAL_MINUTES + 2 minutes (e.g. 17 minutes for a 15-minute poll interval).
Logging
The bridge uses structured JSON logging to stdout (Dozzle-compatible):
{"ts":"2026-06-17T12:23T12:00:00.000Z","level":"INFO","cat":"POLL","msg":"Scheduled poll","userCount":1}
{"ts":"2026-06-17T12:23T12:00:01.000Z","level":"INFO","cat":"CHECKIN","msg":"Processing","venue":"Tim Hortons","lat":43.65,"lon":-79.38}
{"ts":"2026-06-17T12:00:02.000Z","level":"INFO","cat":"CHECKIN","msg":"Sent to Reitti","venue":"Tim Hortons"}
{"ts":"2026-06-17T12:23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Location created","venue":"Tim Hortons","id":"..."}
{"ts":"2026-06-23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Visit created","locationId":"...","date":"2026-06-23"}
{"ts":"2026-06-23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Region marked as visited","regionId":"CA-ON"}
{"ts":"2026-06-23T12:00:03.000Z","level":"INFO","cat":"STATE","msg":"State saved","usersCount":1}
Log categories: SERVER, POLL, CHECKIN, , STATEALAUTH, STATE.
WARN and ERROR entries are also captured in an in-memory ring buffer (last 20) and
displayed on the status dashboard at /.
View live logs via Dozzle at https://logs.home.local or:logs:
docker logs -f swarm-reitti-bridge
Operations and Management
Verify the Bridge is Running
# Container status
docker ps | grep swarm-reitti-bridge
# Health check (includes ok field and per-service status)
curl -s https://swarm.jeeves5454.ddns.net/health | jq .
# JSONStatus statusdashboard
curl -sopen https://swarm.jeeves5454.ddns.net/status | jq .
Re-authenticate with Foursquare
If the OAuth token expires or becomes invalid:invalid (bridge logs show Token expired WARN):
- Visit
https://swarm.jeeves5454.ddns.net/auth - Log in
withandyour Foursquare credentials
withinDo not re-authenticate to retry past check-ins — it will move the
nextsync15-minutebaselinecycleforward and those check-ins will be permanently skipped.
Reprocess Past Check-ins
To force the bridge to reprocess a specific check-in (e.g. after fixing an AL sync error):
cd /home/jeeves/docker/swarmreitti/swarm-reitti-bridge
# View current state
sudo cat data/state.json
# Roll back to just before a specific check-in Unix timestamp
sudo python3 -c "
import json
with open('data/state.json') as f:
s = json.load(f)
s['lastCheckinIds']['<userId>'] = None
s['lastCheckinTimestamps']['<userId>'] = <tst - 1>
with open('data/state.json', 'w') as f:
json.dump(s, f, indent=2)
print('Done')
"
docker compose restart swarm-reitti-bridge
The startup poll will immediately pick up the check-in on restart.
Adjust Polling Interval
Edit thePOLLING_INTERVAL_MINUTES container environment via Portainer (orin the compose file)environment and change
POLLING_INTERVAL_MINUTES. Rebuild/restart the container for the change to take
effect. Guidance:rebuild:
| Interval | API calls/day | Use case |
|---|---|---|
5 |
288 | More responsive (30% quota) |
15 |
96 | Recommended (10% quota) |
30 |
48 | Most conservative (5% quota) |
Rebuild the Container
The image is built locally — there is no upstream registry to pull from:
cd /home/jeeves/docker/swarmreitti/swarm-reitti-bridge
docker compose down
docker compose build --no-cache
docker compose up -d --build
docker compose logs swarm-reitti-bridge -f-follow
Check State File
cat /home/jeeves/docker/swarmreitti/swarm-reitti-bridge/data/state.json | jq .
The state file contains OAuth tokens. Do not expose or commit this file.
Troubleshooting
Check-ins not appearing in Reitti
Check
atbridgethelogsstatusfordashboarderrors:/— the Reitti card shows last push time and any error message
docker logs swarm-reitti-bridge | grep -E '"ERROR|cat":"CHECKIN"|"level":"ERROR"'
Verify Verifytoken is connected: curl -s https://swarm.jeeves5454.ddns.net/health | jq .connectedUsers
— if 0, re-authenticate via /auth
curl -s -o /dev/null -w "%{http_code}" -X POST \
https://reitti.jeeves5454.ddns.net/api/v1/ingest/owntracks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"_type":"location","lat":43.65,"lon":-79.37,"tst":1782088106}'
Verify OAuth token is stored — visitExpected: https://swarm.jeeves5454.ddns.net/status and check . If connectedUsers > 02000,401, re-authenticatethe viatoken in .env is wrong.
Check-ins not appearing in AdventureLog
/authCheck FoursquareAdventureLog API accesskey manuallyis (run on host — substitute real token):
curl -s "https://api.foursquare.com/v2/users/self/checkins?oauth_token=TOKEN&travel.jeeves5454.ddns.net/api/locations?limit=5&v=20240101"1" \
-H "X-API-Key: <key>"
Expected: JSON with count field. If 403, the key is wrong.
Common error — trailing slash causes 308 redirect with lost body: the bridge strips
trailing slashes automatically; if seeing 400s verify the AL URL has no trailing slash
in ADVENTURELOG_API_URL
World Travel region not updating
venue.location.state and venue.location.country
are both present in the Foursquare data. Venues with missing location data are skipped.
If a region resolves but the country/state name doesn't match AdventureLog's English
names, a WARN log is emitted: Could not resolve region. Add a mapping to
FOURSQUARE_COUNTRY_MAP in index.js if needed.
GET /api/visitedregion can confirm which regions are already marked.
OAuth callback fails (redirect URI mismatch)
- Ensure
FOURSQUARE_REDIRECT_URIin env exactly matches the redirect URI registered in the Foursquare developerconsole.
https://swarm.jeeves5454.ddns.net/callback
Bridge container exits immediately
- Check logs:
docker logs swarm-reitti-bridge - Most common cause: missing required environment variable. The app logs all missing vars and exits with code 1.
State file corruption
If state.json is corrupted (e.g., write interrupted on crash):
# The bridge will start fresh (no users connected)sudo rm /home/jeeves/docker/swarmreitti/swarm-reitti-bridge/data/state.json
docker restart swarm-reitti-bridge
# Then re-authenticate via /auth
Source Code
The production build directory:
/home/jeeves/docker/code-server/projects/swarm-reitti-bridge/ ← development
/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/ ← production deployment
├── index.js ← Main application
(polling├── mode,adventurelog-categories.js production← version)Foursquare category → emoji icon map
├── Dockerfile
├── docker-compose.yml
├── package.json ├──← node_modules/version 1.1.0
├── tests/
│ ├── integration.test.js
│ └── unit.test.js
└── data/
└── state.json ← Runtime state (OAuth tokens, last check-in IDs)
The development/reference archive (with all documentation files):
/home/jeeves/docker/swarmreitti/swarm-reitti-FINAL-POLLING/
├── index.js ← Same polling implementation
├── index-polling.js ← Polling version (legacy filename)
├── index-secure.js ← Original push-mode version (for reference only)
├── SOLUTION-SUMMARY.md
├── POLLING-MODE-EXPLAINED.md
├── TROUBLESHOOTING.md
└── ...
Last Updated: 2026-06-1723