Skip to main content

13-swarm-reitti-bridge.md

kstack: book: Centerpoint Home Lab chapter: Documents & Organization page: Swarm-Reitti Bridge tags: [swarm-reitti-bridge, foursquare, reitti, location, custom-built, node]

Overview

emptySwarm-Reitti Bridge is a custom-built Node.js service that automatically syncs Foursquare/Swarm check-ins into Reitti. It was built specifically for this homelab as a bridge between the Foursquare API and Reitti's OwnTracks-compatible ingestion endpoint — keeping all location history self-hosted.

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/swarmreitti/swarm-reitti-FINAL-POLLING/ and the production build at /home/jeeves/docker/swarmreitti/swarm-reitti-bridge/. The container image (swarm-reitti-bridge-swarm-bridge) is built locally via docker compose build and 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
GET /v2/users/self/checkins?oauth_token=...
         ↓
Bridge identifies new check-ins (deduplication via lastCheckinIds)
         ↓
Bridge converts to OwnTracks format
         ↓
POST to Reitti OwnTracks ingest API
Authorization: Bearer <REITTI_API_TOKEN>
         ↓
Check-in appears on Reitti map

Deduplication

The bridge tracks lastCheckinIds per user — a map of userId → most recently processed check-in ID. On each poll, only check-ins newer than the last known ID are forwarded to Reitti. This state is persisted to disk (/app/data/state.json) so it survives container restarts.

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 timestamp per userId lastCheckinIds Last processed check-in ID per userId

Writes are atomic (write to .tmp, then rename) to avoid corruption on crash.


Data Mapping

The bridge converts Foursquare check-in JSON to Reitti's OwnTracks 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 (note: lng → lon) tst checkin.createdAt Unix timestamp of check-in tid First 2 characters of username, uppercased OwnTracks tracker ID acc Hardcoded: 10 Accuracy in metres (Foursquare doesn't provide GPS accuracy) 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

OAuth Setup

Foursquare OAuth 2.0 is used to grant the bridge permission to read check-in data on behalf of the user. 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_id and redirect_uri.

    Authenticate with your Foursquare account and authorise the app.

    Foursquare redirects back to https://swarm.jeeves5454.ddns.net/callback with an authorisation code.

    The bridge exchanges the code for an access token and stores it in the state file. Polling begins automatically.

    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 in polling mode — was /push)

    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 web UI GET /auth Start Foursquare OAuth flow GET /callback OAuth callback (Foursquare redirects here) POST /push Legacy webhook endpoint (inactive in polling mode) GET /health Health check — returns 200 OK with JSON status GET /status JSON status: polling interval, user count, last poll

    The /auth endpoint is rate-limited (10 requests per 15-minute window) to prevent OAuth abuse.


    Configuration

    Environment Variables

    Variable Value / Notes FOURSQUARE_CLIENT_ID REDACTED (from Foursquare developer console) 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 token from Reitti Settings) PUSH_SECRET REDACTED (validates legacy incoming webhooks) POLLING_INTERVAL_MINUTES 15 (default) PORT 3000 NODE_ENV production

    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

    Logging

    The bridge uses structured JSON logging to stdout (Dozzle-compatible):

    {"ts":"2026-06-17T12:00:00.000Z","level":"INFO","cat":"POLL","msg":"Scheduled poll","userCount":1}
    {"ts":"2026-06-17T12: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:00:02.000Z","level":"INFO","cat":"STATE","msg":"State saved","usersCount":1}
    

    Log categories: POLL, CHECKIN, STATE, AUTH, ERROR.

    View live logs via Dozzle at https://logs.home.local or:

    docker logs -f swarm-reitti-bridge
    

    Operations and Management

    Verify the Bridge is Running

    # Container status
    docker ps | grep swarm-reitti-bridge
    
    # Health check
    curl -s https://swarm.jeeves5454.ddns.net/health | jq .
    
    # JSON status
    curl -s https://swarm.jeeves5454.ddns.net/status | jq .
    

    Re-authenticate with Foursquare

    If the OAuth token expires or becomes invalid:

      Visit https://swarm.jeeves5454.ddns.net/auth Log in with your Foursquare credentials Authorise the app The bridge saves the new token automatically Polling resumes within the next 15-minute cycle

      Adjust Polling Interval

      Edit the container environment via Portainer (or the compose file) and change POLLING_INTERVAL_MINUTES. Rebuild/restart the container for the change to take effect. Guidance:

      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
      docker compose logs -f
      

      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 bridge logs for errors:

        docker logs swarm-reitti-bridge | grep -E "ERROR|CHECKIN"
        

        Verify Reitti API connectivity:

        curl -s -o /dev/null -w "%{http_code}" \
          https://reitti.jeeves5454.ddns.net/api/v1/ingest/owntracks
        

        Verify OAuth token is stored — visit https://swarm.jeeves5454.ddns.net/status and check connectedUsers > 0. If 0, re-authenticate via /auth.

        Check Foursquare API access manually (run on host — substitute real token):

        curl "https://api.foursquare.com/v2/users/self/checkins?oauth_token=TOKEN&limit=5&v=20240101"
        

        OAuth callback fails (redirect URI mismatch)

          Ensure FOURSQUARE_REDIRECT_URI in env exactly matches the redirect URI registered in the Foursquare developer console. The value must be: 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)
            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/swarmreitti/swarm-reitti-bridge/
            ├── index.js           ← Main application (polling mode, production version)
            ├── Dockerfile
            ├── docker-compose.yml
            ├── package.json
            ├── node_modules/
            ├── tests/
            └── 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-17