aprs.me/docs/mobile-api.md
Graham McIntire 09f92c6738
feat(mobile): rate-limit socket + batch historical packet delivery
- MobileUserSocket: cap new socket connections per IP at 30/min via
  Aprsme.RateLimiter. Denials log and return :error at handshake.
  Tests bypass the check (no real peer_data).

- MobileChannel: rate-limit the expensive client-initiated handlers
  (subscribe_bounds, subscribe_callsign, search_callsign) at
  30/minute per socket. Streaming packet delivery is unaffected.

- MobileChannel: historical packet loads now arrive as a batched
  `packets` event (100 per frame) instead of one `packet` frame per
  row. Live streaming still uses the single `packet` event.

- Updated docs/mobile-api.md with the `packets` event and rate-limit
  semantics; migration note preserves compatibility for clients that
  only handle live `packet` events.
2026-04-21 09:38:51 -05:00

13 KiB

Mobile API Documentation

Overview

The APRS.me Mobile API provides real-time streaming of APRS packets filtered by geographic bounds for iOS and Android applications.

Connection

WebSocket URL: wss://aprs.me/mobile/websocket

Protocol: Phoenix Channels over WebSocket

Authentication

Currently, connections are anonymous. Future versions will support token-based authentication.

Channel: mobile:packets

Joining

{
  "topic": "mobile:packets",
  "event": "phx_join",
  "payload": {},
  "ref": "1"
}

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Connected to APRS mobile channel"
    }
  },
  "ref": "1"
}

Subscribing to Geographic Bounds

Subscribe to receive packets within specific geographic bounds. Upon subscription, the server will immediately send historical packets within the bounds, followed by real-time streaming packets.

Event: subscribe_bounds

Payload:

{
  "north": 33.2,
  "south": 33.0,
  "east": -96.0,
  "west": -96.2,
  "limit": 1000,
  "hours_back": 1
}

Payload Fields:

Field Type Required Default Description
north float Yes - Northern boundary latitude
south float Yes - Southern boundary latitude
east float Yes - Eastern boundary longitude
west float Yes - Western boundary longitude
limit integer No 1000 Maximum historical packets to load (max: 5000)
hours_back integer No 1 How many hours of historical data to load (max: 24)

Example:

{
  "topic": "mobile:packets",
  "event": "subscribe_bounds",
  "payload": {
    "north": 33.2,
    "south": 33.0,
    "east": -96.0,
    "west": -96.2,
    "limit": 2000,
    "hours_back": 2
  },
  "ref": "2"
}

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "bounds": {
        "north": 33.2,
        "south": 33.0,
        "east": -96.0,
        "west": -96.2
      },
      "message": "Subscribed to packet stream"
    }
  },
  "ref": "2"
}

Updating Bounds

Update the geographic bounds (e.g., when user pans/zooms the map). This updates the real-time streaming filter only; it does not re-send historical packets for the new bounds.

Event: update_bounds

Payload:

{
  "north": 33.3,
  "south": 32.9,
  "east": -95.9,
  "west": -96.3
}

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "bounds": {
        "north": 33.3,
        "south": 32.9,
        "east": -95.9,
        "west": -96.3
      },
      "message": "Bounds updated"
    }
  },
  "ref": "3"
}

Receiving Packets

Once subscribed, you'll receive packets within your bounds. Historical packets are sent immediately after subscription, followed by real-time streaming packets as they arrive.

Event: packet

Payload:

{
  "id": "d7249877-d4a6-45c2-b314-2a8a355d2566",
  "callsign": "K5GVL-10",
  "lat": 33.1225,
  "lng": -96.124,
  "timestamp": "2025-10-25T16:17:20Z",
  "symbol_table_id": "/",
  "symbol_code": "#",
  "comment": "6/SVARA U=13.9V,T=75.3F",
  "altitude": 150.5,
  "speed": 45.2,
  "course": 180,
  "path": "TCPIP*,qAS,K5GVL"
}

Packet Fields:

Field Type Description Required
id string Unique packet identifier (UUID) Yes
callsign string Station callsign (sender). For APRS objects/items, this is still the sender callsign, not the object name. Yes
lat float Latitude (-90 to 90) Yes
lng float Longitude (-180 to 180) Yes
timestamp string ISO 8601 timestamp Yes
symbol_table_id string APRS symbol table (/, \) Yes
symbol_code string APRS symbol code Yes
comment string Station comment/status Optional
altitude float Altitude in feet Optional
speed float Speed in knots Optional
course integer Course in degrees (0-359) Optional
path string APRS digipeater path Optional

Note: Optional fields are omitted from the payload entirely when their value is nil.

Receiving Batched Packets (Historical Loads)

When the server delivers historical packets in bulk (for example, the replay that follows subscribe_bounds or subscribe_callsign), they arrive as a batched packets event instead of many individual packet events. Live streaming packets continue to use the single packet event described above.

Event: packets

Payload:

{
  "packets": [
    { "id": "...", "callsign": "...", "lat": 33.1, "lng": -96.1, "timestamp": "2025-10-25T16:17:20Z", "symbol_table_id": "/", "symbol_code": ">" },
    { "id": "...", "callsign": "...", "lat": 33.2, "lng": -96.2, "timestamp": "2025-10-25T16:17:25Z", "symbol_table_id": "/", "symbol_code": "#" }
  ],
  "count": 2
}

Each entry inside packets has the same schema as the single-packet packet event above. Batch sizes are capped at 100 entries per frame, so a 1,000-packet history load arrives as ~10 packets events.

Migration guidance: existing clients that only listen for the packet event will still receive live updates correctly — they simply won't populate the historical backlog. To show history, add a handler for packets that iterates over payload.packets and applies the same rendering logic.

Rate Limiting

  • Connect: up to 30 new socket connections per IP per minute. Exceeding the limit returns an immediate :error from the socket handshake; retry after the minute window elapses.
  • Channel requests: the expensive operations (subscribe_bounds, subscribe_callsign, search_callsign) are rate-limited to 30 calls per socket per minute. Exceeding the limit returns { "status": "error", "response": { "message": "Rate limit exceeded; retry later", "retry_after_ms": <ms> } }.
  • Streaming packet delivery is not rate-limited.

Unsubscribing

Stop receiving packets.

Event: unsubscribe

Payload: {}

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Unsubscribed from packet stream"
    }
  },
  "ref": "4"
}

Searching for Callsigns

Search for callsigns matching a pattern.

Event: search_callsign

Payload:

{
  "query": "W5ISP",
  "limit": 50
}

Payload Fields:

Field Type Required Default Description
query string Yes - Callsign to search (supports * wildcard)
limit integer No 50 Maximum results to return (max: 500)

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "results": [
        {
          "callsign": "W5ISP-9",
          "base_callsign": "W5ISP",
          "last_seen": "2025-10-25T17:30:00Z",
          "lat": 33.1225,
          "lon": -96.124
        }
      ],
      "count": 1
    }
  },
  "ref": "5"
}

Examples:

  • "W5ISP" - Finds W5ISP and all SSIDs (W5ISP-1, W5ISP-9, etc.)
  • "W5ISP-9" - Finds only W5ISP-9
  • "W5ISP*" - Finds all callsigns starting with W5ISP

Subscribing to Callsign Updates

Subscribe to live updates for a specific callsign or pattern. When subscribed, you'll receive historical packets for that callsign, followed by real-time updates.

Event: subscribe_callsign

Payload:

{
  "callsign": "W5ISP-9",
  "hours_back": 24
}

Payload Fields:

Field Type Required Default Description
callsign string Yes - Callsign to track (supports * wildcard)
hours_back integer No 24 Hours of historical data to load (max: 168)

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "callsign": "W5ISP-9",
      "message": "Subscribed to callsign updates"
    }
  },
  "ref": "6"
}

Note: When tracking a callsign, you'll receive packets matching that callsign even if they're outside your geographic bounds. To receive only callsign packets within bounds, use both subscribe_bounds and subscribe_callsign together.

Unsubscribing from Callsign Updates

Stop receiving updates for the tracked callsign.

Event: unsubscribe_callsign

Payload: {}

Response:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Unsubscribed from callsign updates"
    }
  },
  "ref": "7"
}

Error Responses

Invalid Bounds:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "error",
    "response": {
      "message": "North must be greater than south"
    }
  },
  "ref": "2"
}

Not Subscribed:

{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "error",
    "response": {
      "message": "Not subscribed. Call subscribe_bounds first."
    }
  },
  "ref": "3"
}

Swift Example

import SwiftPhoenixClient

class APRSService {
    let socket: Socket
    var channel: Channel?

    init() {
        socket = Socket("wss://aprs.me/mobile/websocket")
        socket.connect()
    }

    func joinChannel() {
        channel = socket.channel("mobile:packets")

        channel?.on("packet") { message in
            if let packet = message.payload as? [String: Any] {
                self.handlePacket(packet)
            }
        }

        channel?.join()
            .receive("ok") { _ in
                print("Joined mobile:packets channel")
                self.subscribeToBounds()
            }
            .receive("error") { error in
                print("Failed to join: \\(error)")
            }
    }

    func subscribeToBounds(north: Double, south: Double, east: Double, west: Double) {
        let bounds = [
            "north": north,
            "south": south,
            "east": east,
            "west": west
        ]

        channel?.push("subscribe_bounds", payload: bounds)
            .receive("ok") { response in
                print("Subscribed to bounds: \\(response)")
            }
            .receive("error") { error in
                print("Subscribe error: \\(error)")
            }
    }

    func updateBounds(north: Double, south: Double, east: Double, west: Double) {
        let bounds = [
            "north": north,
            "south": south,
            "east": east,
            "west": west
        ]

        channel?.push("update_bounds", payload: bounds)
    }

    func handlePacket(_ packet: [String: Any]) {
        guard let callsign = packet["callsign"] as? String,
              let lat = packet["lat"] as? Double,
              let lng = packet["lng"] as? Double else {
            return
        }

        // Update your map with the new packet
        print("Received packet from \\(callsign) at \\(lat), \\(lng)")
    }
}

SwiftUI MapKit Integration

import SwiftUI
import MapKit

struct APRSMapView: View {
    @StateObject private var aprsService = APRSService()
    @State private var region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 33.1, longitude: -96.1),
        span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
    )

    var body: some View {
        Map(coordinateRegion: $region)
            .onAppear {
                aprsService.joinChannel()
            }
            .onChange(of: region) { newRegion in
                let center = newRegion.center
                let span = newRegion.span

                aprsService.updateBounds(
                    north: center.latitude + span.latitudeDelta / 2,
                    south: center.latitude - span.latitudeDelta / 2,
                    east: center.longitude + span.longitudeDelta / 2,
                    west: center.longitude - span.longitudeDelta / 2
                )
            }
    }
}

Connection Timeout

WebSocket connections have a 60-second heartbeat timeout. If the server does not receive a heartbeat within 60 seconds, the connection is closed. Phoenix Channels clients send heartbeats automatically (every 30 seconds by default).

Best Practices

  1. Update bounds only when map movement stops - Use a debounce to avoid excessive updates
  2. Unsubscribe when app backgrounds - Save battery and bandwidth
  3. Handle reconnection - Phoenix Channels will automatically reconnect on connection loss
  4. Validate coordinates - Always check lat/lng before adding to map
  5. Limit visible area - Don't subscribe to bounds larger than what the user can see

Dependencies

Swift:

Installation (Swift Package Manager):

dependencies: [
    .package(url: "https://github.com/davidstump/SwiftPhoenixClient.git", from: "5.3.0")
]

Support

For issues or questions: