- 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.
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
:errorfrom 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
- Update bounds only when map movement stops - Use a debounce to avoid excessive updates
- Unsubscribe when app backgrounds - Save battery and bandwidth
- Handle reconnection - Phoenix Channels will automatically reconnect on connection loss
- Validate coordinates - Always check lat/lng before adding to map
- Limit visible area - Don't subscribe to bounds larger than what the user can see
Dependencies
Swift:
- SwiftPhoenixClient - Phoenix Channels client
Installation (Swift Package Manager):
dependencies: [
.package(url: "https://github.com/davidstump/SwiftPhoenixClient.git", from: "5.3.0")
]
Support
For issues or questions:
- GitHub: https://github.com/aprsme/aprs.me/issues
- Documentation: https://docs.aprs.me