API Endpoints Reference
Complete reference for all Vail ReRBN API endpoints with parameters, examples, and response formats.
GET /spots
Search for spots with optional filters. This is the primary endpoint for querying spot data.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
call |
string | — | Callsign to search (partial match, case-insensitive) |
spotter |
string | — | Filter by spotter/skimmer callsign |
mode |
string | — | Mode filter: CW, RTTY, FT8, FT4 (comma-separated) |
band |
string | — | Amateur band: 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m |
since |
timestamp | 1 hour ago | Start time (Unix timestamp or ISO 8601) |
until |
timestamp | now | End time (Unix timestamp or ISO 8601) |
limit |
integer | 100 | Max results (1-1000) |
offset |
integer | 0 | Pagination offset |
after_id |
integer | — | Return spots with ID > this value (for real-time polling) |
Response Fields
| Field | Type | Description |
|---|---|---|
id |
integer | Unique spot identifier |
timestamp |
string | ISO 8601 timestamp |
spotter |
string | RBN skimmer callsign |
spotter_grid |
string | null | Maidenhead grid of the skimmer (e.g., "FM19"). Null if unknown. |
callsign |
string | Spotted station (DX) callsign |
grid |
string | null | Maidenhead grid of the spotted station (e.g., "FN31pr"). Null if unknown. |
frequency |
number | Frequency in kHz |
mode |
string | Mode: CW, RTTY, FT8, FT4, etc. |
snr |
integer | Signal-to-noise ratio in dB |
wpm |
integer | null | Words per minute (CW only) |
Gridsquares are looked up from HamDB.org. Coverage is best for US callsigns.
International callsigns may return null. New callsigns are queued for
background lookup and will be available on subsequent requests.
Example
curl "https://www.vailrerbn.com/api/v1/spots?call=W1AW&mode=CW&band=20m"
import requests
response = requests.get(
"https://www.vailrerbn.com/api/v1/spots",
params={
"call": "W1AW",
"mode": "CW",
"band": "20m"
}
)
data = response.json()
print(f"Found {data['total']} spots")
for spot in data["spots"]:
grid = spot.get('grid', 'Unknown')
print(f"{spot['callsign']} ({grid}) on {spot['frequency']} kHz")
const params = new URLSearchParams({
call: 'W1AW',
mode: 'CW',
band: '20m'
});
const response = await fetch(
`https://www.vailrerbn.com/api/v1/spots?${params}`
);
const data = await response.json();
console.log(`Found ${data.total} spots`);
data.spots.forEach(spot => {
const grid = spot.grid || 'Unknown';
console.log(`${spot.callsign} (${grid}) on ${spot.frequency} kHz`);
});
<?php
$params = http_build_query([
'call' => 'W1AW',
'mode' => 'CW',
'band' => '20m'
]);
$response = file_get_contents(
"https://www.vailrerbn.com/api/v1/spots?$params"
);
$data = json_decode($response, true);
echo "Found {$data['total']} spots\n";
foreach ($data['spots'] as $spot) {
echo "{$spot['callsign']} on {$spot['frequency']} kHz\n";
}
require 'net/http'
require 'json'
uri = URI('https://www.vailrerbn.com/api/v1/spots')
uri.query = URI.encode_www_form({
call: 'W1AW',
mode: 'CW',
band: '20m'
})
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "Found #{data['total']} spots"
data['spots'].each do |spot|
puts "#{spot['callsign']} on #{spot['frequency']} kHz"
end
Find recent spots for W1AW on the 20-meter band in CW mode
GET /spots/:callsign
Get all spots for a specific callsign (exact match).
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 24 | Time range in hours |
mode |
string | — | Filter by mode |
Example
curl "https://www.vailrerbn.com/api/v1/spots/W1AW?hours=48"
import requests
callsign = "W1AW"
hours = 48
response = requests.get(
f"https://www.vailrerbn.com/api/v1/spots/{callsign}",
params={"hours": hours}
)
data = response.json()
print(f"Found {data['total']} spots for {callsign} in last {hours} hours")
for spot in data["spots"]:
spotter_grid = spot.get('spotter_grid', '????')
print(f"{spot['timestamp']} - {spot['spotter']} ({spotter_grid}) - {spot['frequency']} kHz - {spot['mode']} - {spot['snr']} dB")
const callsign = 'W1AW';
const hours = 48;
const response = await fetch(
`https://www.vailrerbn.com/api/v1/spots/${callsign}?hours=${hours}`
);
const data = await response.json();
console.log(`Found ${data.total} spots for ${callsign} in last ${hours} hours`);
data.spots.forEach(spot => {
const spotterGrid = spot.spotter_grid || '????';
console.log(`${spot.timestamp} - ${spot.spotter} (${spotterGrid}) - ${spot.frequency} kHz - ${spot.mode} - ${spot.snr} dB`);
});
<?php
$callsign = "W1AW";
$hours = 48;
$url = "https://www.vailrerbn.com/api/v1/spots/$callsign?" . http_build_query(['hours' => $hours]);
$response = file_get_contents($url);
$data = json_decode($response, true);
echo "Found {$data['total']} spots for $callsign in last $hours hours\n";
foreach ($data['spots'] as $spot) {
echo "{$spot['timestamp']} - {$spot['frequency']} kHz - {$spot['mode']} - {$spot['snr']} dB\n";
}
require 'net/http'
require 'json'
callsign = 'W1AW'
hours = 48
uri = URI("https://www.vailrerbn.com/api/v1/spots/#{callsign}")
uri.query = URI.encode_www_form({ hours: hours })
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "Found #{data['total']} spots for #{callsign} in last #{hours} hours"
data['spots'].each do |spot|
puts "#{spot['timestamp']} - #{spot['frequency']} kHz - #{spot['mode']} - #{spot['snr']} dB"
end
Retrieve all spots for W1AW in the past 48 hours
GET /activity
Batch activity check for multiple callsigns in a single request. Returns whether each callsign has been spotted, along with spot count and latest spot details. Designed for monitoring a list of callsigns without making individual API calls.
If you're monitoring 50-100 callsigns, using /spots/:callsign for each one means 50-100 separate HTTP requests. This endpoint handles all of them in a single request with a single efficient database query. Much faster for both you and the server.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
call |
string | required | Comma-separated callsigns (max 100). Example: W1AW,K1TTT,N3LLO |
hours |
integer | 1 | Time window in hours (1-168) |
Response Fields
| Field | Type | Description |
|---|---|---|
callsigns[].callsign |
string | The callsign (uppercase) |
callsigns[].spotted |
boolean | true if at least one spot exists in the time window |
callsigns[].count |
integer | Number of spots in the time window (0 if not spotted) |
callsigns[].last_spotted |
string | null | ISO 8601 timestamp of the most recent spot |
callsigns[].last_frequency |
number | null | Frequency (kHz) of the most recent spot |
callsigns[].last_mode |
string | null | Mode of the most recent spot (CW, FT8, etc.) |
callsigns[].last_snr |
integer | null | SNR (dB) of the most recent spot |
summary.requested |
integer | Total callsigns requested |
summary.spotted |
integer | Number of callsigns that were spotted |
summary.not_spotted |
integer | Number of callsigns with no spots |
Example Response
{
"callsigns": [
{
"callsign": "W1AW",
"spotted": true,
"count": 12,
"last_spotted": "2026-02-17T15:32:00.000Z",
"last_frequency": 14025.3,
"last_mode": "CW",
"last_snr": 25
},
{
"callsign": "K1TTT",
"spotted": false,
"count": 0,
"last_spotted": null,
"last_frequency": null,
"last_mode": null,
"last_snr": null
}
],
"summary": {
"requested": 2,
"spotted": 1,
"not_spotted": 1
},
"hours": 1
}
Example
# Check 5 callsigns in a single request
curl "https://www.vailrerbn.com/api/v1/activity?call=W1AW,K1TTT,N3LLO,WB9Z,VE3EN&hours=1"
import requests
# List of callsigns to monitor
callsigns = ["W1AW", "K1TTT", "N3LLO", "WB9Z", "VE3EN"]
response = requests.get(
"https://www.vailrerbn.com/api/v1/activity",
params={
"call": ",".join(callsigns),
"hours": 1
}
)
data = response.json()
print(f"{data['summary']['spotted']}/{data['summary']['requested']} callsigns active")
for entry in data["callsigns"]:
if entry["spotted"]:
print(f" {entry['callsign']}: {entry['count']} spots, "
f"last on {entry['last_frequency']} kHz ({entry['last_mode']})")
else:
print(f" {entry['callsign']}: not spotted")
const callsigns = ['W1AW', 'K1TTT', 'N3LLO', 'WB9Z', 'VE3EN'];
const response = await fetch(
`https://www.vailrerbn.com/api/v1/activity?call=${callsigns.join(',')}&hours=1`
);
const data = await response.json();
console.log(`${data.summary.spotted}/${data.summary.requested} callsigns active`);
for (const entry of data.callsigns) {
if (entry.spotted) {
console.log(` ${entry.callsign}: ${entry.count} spots, `
+ `last on ${entry.last_frequency} kHz (${entry.last_mode})`);
} else {
console.log(` ${entry.callsign}: not spotted`);
}
}
<?php
$callsigns = ["W1AW", "K1TTT", "N3LLO", "WB9Z", "VE3EN"];
$params = http_build_query([
'call' => implode(',', $callsigns),
'hours' => 1
]);
$response = file_get_contents(
"https://www.vailrerbn.com/api/v1/activity?$params"
);
$data = json_decode($response, true);
echo "{$data['summary']['spotted']}/{$data['summary']['requested']} callsigns active\n";
foreach ($data['callsigns'] as $entry) {
if ($entry['spotted']) {
echo " {$entry['callsign']}: {$entry['count']} spots, "
. "last on {$entry['last_frequency']} kHz ({$entry['last_mode']})\n";
} else {
echo " {$entry['callsign']}: not spotted\n";
}
}
require 'net/http'
require 'json'
callsigns = %w[W1AW K1TTT N3LLO WB9Z VE3EN]
uri = URI('https://www.vailrerbn.com/api/v1/activity')
uri.query = URI.encode_www_form({
call: callsigns.join(','),
hours: 1
})
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "#{data['summary']['spotted']}/#{data['summary']['requested']} callsigns active"
data['callsigns'].each do |entry|
if entry['spotted']
puts " #{entry['callsign']}: #{entry['count']} spots, " \
"last on #{entry['last_frequency']} kHz (#{entry['last_mode']})"
else
puts " #{entry['callsign']}: not spotted"
end
end
Check if multiple callsigns have been spotted recently - much more efficient than individual calls
Callsign Groups
Create and manage persistent, named groups of callsigns for batch monitoring. Groups are public — anyone can view and query them. Modifications require an edit key returned at creation time.
Instead of passing a long list of callsigns to /activity every time, create a group once and query it by name. Perfect for contest teams, DXpedition monitoring, club rosters, or any recurring set of callsigns.
You can also browse and view groups on the Groups page.
POST /groups — Create a Group
Create a new named callsign group. Returns an edit_key that is shown only once — save it to modify or delete the group later.
Request Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | 3-50 chars, lowercase alphanumeric + hyphens (e.g., my-contest-team) |
callsigns |
string[] | Yes | Array of callsigns (1-500) |
creator_name |
string | Yes | Your name or callsign (max 100 chars). Visible to others so they can contact you about group changes. |
creator_email |
string | Yes | Your email address. Visible on the group page so others can reach you for adjustment requests. |
description |
string | No | Optional description (max 200 chars) |
Example
curl -X POST https://www.vailrerbn.com/api/v1/groups \
-H "Content-Type: application/json" \
-d '{
"name": "contest-team-alpha",
"callsigns": ["W1AW", "K1TTT", "N3LLO"],
"creator_name": "Brett KE9BOS",
"creator_email": "ke9bos@pigletradio.org",
"description": "Our ARRL DX contest team"
}'
Response (201 Created)
{
"group": {
"name": "contest-team-alpha",
"callsigns": ["W1AW", "K1TTT", "N3LLO"],
"description": "Our ARRL DX contest team",
"creator_name": "Brett KE9BOS",
"creator_email": "ke9bos@pigletradio.org",
"callsign_count": 3,
"created_at": "2026-02-17T12:00:00.000Z",
"updated_at": "2026-02-17T12:00:00.000Z"
},
"edit_key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"notice": "Save this edit_key! It is required to modify or delete this group and will not be shown again."
}
The edit_key is only shown once at creation time. You need it to update or delete the group.
If you lose it, contact ke9bos@pigletradio.org for recovery.
GET /groups — List All Groups
Returns all groups with name, description, and callsign count. No authentication required.
Example
curl https://www.vailrerbn.com/api/v1/groups
Response
{
"groups": [
{
"name": "contest-team-alpha",
"description": "Our ARRL DX contest team",
"creator_name": "Brett KE9BOS",
"callsign_count": 3,
"created_at": "2026-02-17T12:00:00.000Z"
}
],
"total": 1
}
GET /groups/:name — Get Group Details
Returns the full group details including the callsign list, creator info, and timestamps.
Example
curl https://www.vailrerbn.com/api/v1/groups/contest-team-alpha
Response
{
"group": {
"name": "contest-team-alpha",
"description": "Our ARRL DX contest team",
"creator_name": "Brett KE9BOS",
"creator_email": "ke9bos@pigletradio.org",
"callsigns": ["K1TTT", "N3LLO", "W1AW"],
"callsign_count": 3,
"created_at": "2026-02-17T12:00:00.000Z",
"updated_at": "2026-02-17T12:00:00.000Z"
}
}
PUT /groups/:name — Update a Group
Update callsigns and/or description. Supports three modes for managing callsigns: full replacement, adding, or removing individual callsigns. Requires edit_key via header or query parameter.
Authentication
Provide the edit key in one of two ways:
- Header:
X-Edit-Key: your_key_here - Query parameter:
?edit_key=your_key_here
Request Body (JSON, all fields optional)
| Field | Type | Description |
|---|---|---|
callsigns |
string[] | Full replacement — replaces the entire callsign list with this array |
add_callsigns |
string[] | Incremental add — adds these callsigns to the existing list (duplicates ignored) |
remove_callsigns |
string[] | Incremental remove — removes these callsigns from the existing list |
description |
string | New description (can be combined with any callsign operation) |
callsigns together with add_callsigns or remove_callsigns in the same request.
Use callsigns for full replacement, or add_callsigns/remove_callsigns for incremental changes.
You may use add_callsigns and remove_callsigns together in one request.
Example: Full Replacement
curl -X PUT https://www.vailrerbn.com/api/v1/groups/contest-team-alpha \
-H "Content-Type: application/json" \
-H "X-Edit-Key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \
-d '{
"callsigns": ["W1AW", "K1TTT", "N3LLO", "WB9Z"],
"description": "Updated team roster"
}'
Example: Add Callsigns
curl -X PUT https://www.vailrerbn.com/api/v1/groups/contest-team-alpha \
-H "Content-Type: application/json" \
-H "X-Edit-Key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \
-d '{
"add_callsigns": ["N3LLO", "WB9Z"]
}'
Example: Remove Callsigns
curl -X PUT https://www.vailrerbn.com/api/v1/groups/contest-team-alpha \
-H "Content-Type: application/json" \
-H "X-Edit-Key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \
-d '{
"remove_callsigns": ["K1TTT"]
}'
DELETE /groups/:name — Delete a Group
Permanently delete a group. Requires edit_key.
Example
curl -X DELETE https://www.vailrerbn.com/api/v1/groups/contest-team-alpha \
-H "X-Edit-Key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
GET /groups/:name/activity — Group Activity Check
Check which callsigns in the group have been spotted. Same response format as /activity with an added group field.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 1 | Time window in hours (1-168) |
Example
curl "https://www.vailrerbn.com/api/v1/groups/contest-team-alpha/activity?hours=1"
Response
{
"group": "contest-team-alpha",
"callsigns": [
{
"callsign": "W1AW",
"spotted": true,
"count": 12,
"last_spotted": "2026-02-17T15:32:00.000Z",
"last_frequency": 14025.3,
"last_mode": "CW",
"last_snr": 25
},
{
"callsign": "K1TTT",
"spotted": false,
"count": 0,
"last_spotted": null,
"last_frequency": null,
"last_mode": null,
"last_snr": null
}
],
"summary": {
"requested": 2,
"spotted": 1,
"not_spotted": 1
},
"hours": 1
}
GET /groups/:name/spots — Group Spot Data
Get full spot data for all callsigns in the group. Same response format as /spots with an added group field. Includes gridsquare enrichment.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 1 | Time window in hours (1-168) |
mode |
string | — | Mode filter (comma-separated: CW, FT8, etc.) |
band |
string | — | Band filter (20m, 40m, etc.) |
limit |
integer | 100 | Max results (1-1000) |
offset |
integer | 0 | Pagination offset |
Example
curl "https://www.vailrerbn.com/api/v1/groups/contest-team-alpha/spots?hours=24&mode=CW&limit=50"
Error Responses
| Status | Cause |
|---|---|
| 400 | Invalid name, callsigns, or description |
| 401 | Missing or invalid edit key (for PUT/DELETE) |
| 404 | Group not found |
| 409 | Group name already exists (on POST) |
| 429 | Global group limit reached (max 1000) |
Group Name Rules
- 3-50 characters
- Lowercase letters, numbers, and hyphens only
- Must start and end with a letter or number
- No consecutive hyphens
- Reserved names:
new,list,all,admin,api,test,create,delete,update
Automatic Cleanup
Groups that have not been queried or modified in 90 days are automatically deleted. Any read or query resets the timer.
GET /stats
Get aggregate statistics including total spots, spots/min, mode breakdown, and top bands.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 1 | Time range from now (use start_hours/end_hours for historical windows) |
start_hours |
integer | 1 | How many hours ago to start the window (older boundary) |
end_hours |
integer | 0 | How many hours ago to end the window (0 = now) |
hours=1— Last 1 hour (ends at now)start_hours=4&end_hours=0— Last 4 hours (same ashours=4)start_hours=4&end_hours=3— Data from 4 hours ago to 3 hours ago (1-hour window)start_hours=24&end_hours=12— Data from 24 hours ago to 12 hours ago
Example
curl "https://www.vailrerbn.com/api/v1/stats?hours=1"
import requests
response = requests.get(
"https://www.vailrerbn.com/api/v1/stats",
params={"hours": 1}
)
data = response.json()
print(f"Total spots: {data['totalSpots']}")
print(f"Spots/min: {data['spotsPerMinute']:.1f}")
print(f"Unique callsigns: {data['uniqueCallsigns']}")
print(f"\nMode breakdown:")
for mode, count in data['modeBreakdown'].items():
print(f" {mode}: {count}")
const response = await fetch(
'https://www.vailrerbn.com/api/v1/stats?hours=1'
);
const data = await response.json();
console.log(`Total spots: ${data.totalSpots}`);
console.log(`Spots/min: ${data.spotsPerMinute.toFixed(1)}`);
console.log(`Unique callsigns: ${data.uniqueCallsigns}`);
console.log(`\nMode breakdown:`);
for (const [mode, count] of Object.entries(data.modeBreakdown)) {
console.log(` ${mode}: ${count}`);
}
<?php
$response = file_get_contents(
"https://www.vailrerbn.com/api/v1/stats?hours=1"
);
$data = json_decode($response, true);
echo "Total spots: {$data['totalSpots']}\n";
echo "Spots/min: " . number_format($data['spotsPerMinute'], 1) . "\n";
echo "Unique callsigns: {$data['uniqueCallsigns']}\n";
echo "\nMode breakdown:\n";
foreach ($data['modeBreakdown'] as $mode => $count) {
echo " $mode: $count\n";
}
require 'net/http'
require 'json'
uri = URI('https://www.vailrerbn.com/api/v1/stats')
uri.query = URI.encode_www_form({ hours: 1 })
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "Total spots: #{data['totalSpots']}"
puts "Spots/min: #{'%.1f' % data['spotsPerMinute']}"
puts "Unique callsigns: #{data['uniqueCallsigns']}"
puts "\nMode breakdown:"
data['modeBreakdown'].each do |mode, count|
puts " #{mode}: #{count}"
end
Fetch aggregated stats for the past hour
Historical Time Window Example
# Get stats from 4 hours ago to 3 hours ago (1-hour window)
curl "https://www.vailrerbn.com/api/v1/stats?start_hours=4&end_hours=3"
import requests
# Get stats from 4 hours ago to 3 hours ago
response = requests.get(
"https://www.vailrerbn.com/api/v1/stats",
params={
"start_hours": 4,
"end_hours": 3
}
)
data = response.json()
print(f"Time range: {data['timeRange']['start']} to {data['timeRange']['end']}")
print(f"Total spots: {data['totalSpots']}")
print(f"Spots/min: {data['spotsPerMinute']:.1f}")
// Get stats from 4 hours ago to 3 hours ago
const response = await fetch(
'https://www.vailrerbn.com/api/v1/stats?start_hours=4&end_hours=3'
);
const data = await response.json();
console.log(`Time range: ${data.timeRange.start} to ${data.timeRange.end}`);
console.log(`Total spots: ${data.totalSpots}`);
console.log(`Spots/min: ${data.spotsPerMinute.toFixed(1)}`);
<?php
// Get stats from 4 hours ago to 3 hours ago
$response = file_get_contents(
"https://www.vailrerbn.com/api/v1/stats?start_hours=4&end_hours=3"
);
$data = json_decode($response, true);
echo "Time range: {$data['timeRange']['start']} to {$data['timeRange']['end']}\n";
echo "Total spots: {$data['totalSpots']}\n";
echo "Spots/min: " . number_format($data['spotsPerMinute'], 1) . "\n";
require 'net/http'
require 'json'
# Get stats from 4 hours ago to 3 hours ago
uri = URI('https://www.vailrerbn.com/api/v1/stats')
uri.query = URI.encode_www_form({
start_hours: 4,
end_hours: 3
})
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "Time range: #{data['timeRange']['start']} to #{data['timeRange']['end']}"
puts "Total spots: #{data['totalSpots']}"
puts "Spots/min: #{'%.1f' % data['spotsPerMinute']}"
Fetch stats for a specific time window (e.g., 4 to 3 hours ago)
GET /skimmers
Get list of active skimmers/spotters, sorted by spot count.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 1 | Time range in hours |
Note: Returns up to 100 skimmers.
GET /charts
Get chart data for visualizations (hourly activity, mode breakdown, band breakdown, top callsigns).
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
hours |
integer | 12 | Time range from now (use start_hours/end_hours for historical windows) |
start_hours |
integer | 12 | How many hours ago to start the window (older boundary) |
end_hours |
integer | 0 | How many hours ago to end the window (0 = now) |
hours=12— Last 12 hours (ends at now)start_hours=24&end_hours=12— Data from 24 hours ago to 12 hours agostart_hours=48&end_hours=24— Yesterday's full 24-hour window
Note: Hourly activity data excludes the current incomplete hour when end_hours=0.
Historical Time Window Example
# Get chart data from 48 hours ago to 24 hours ago (yesterday)
curl "https://www.vailrerbn.com/api/v1/charts?start_hours=48&end_hours=24"
import requests
# Get chart data from 48 hours ago to 24 hours ago (yesterday)
response = requests.get(
"https://www.vailrerbn.com/api/v1/charts",
params={
"start_hours": 48,
"end_hours": 24
}
)
data = response.json()
print(f"Hourly activity ({len(data['hourlyActivity'])} hours):")
for hour in data['hourlyActivity']:
print(f" {hour['hour']}: {hour['count']} spots")
print(f"\nTop modes:")
for mode in data['byMode'][:3]:
print(f" {mode['mode']}: {mode['count']}")
// Get chart data from 48 hours ago to 24 hours ago (yesterday)
const response = await fetch(
'https://www.vailrerbn.com/api/v1/charts?start_hours=48&end_hours=24'
);
const data = await response.json();
console.log(`Hourly activity (${data.hourlyActivity.length} hours):`);
data.hourlyActivity.forEach(hour => {
console.log(` ${hour.hour}: ${hour.count} spots`);
});
console.log(`\nTop modes:`);
data.byMode.slice(0, 3).forEach(mode => {
console.log(` ${mode.mode}: ${mode.count}`);
});
<?php
// Get chart data from 48 hours ago to 24 hours ago (yesterday)
$response = file_get_contents(
"https://www.vailrerbn.com/api/v1/charts?start_hours=48&end_hours=24"
);
$data = json_decode($response, true);
echo "Hourly activity (" . count($data['hourlyActivity']) . " hours):\n";
foreach ($data['hourlyActivity'] as $hour) {
echo " {$hour['hour']}: {$hour['count']} spots\n";
}
echo "\nTop modes:\n";
foreach (array_slice($data['byMode'], 0, 3) as $mode) {
echo " {$mode['mode']}: {$mode['count']}\n";
}
require 'net/http'
require 'json'
# Get chart data from 48 hours ago to 24 hours ago (yesterday)
uri = URI('https://www.vailrerbn.com/api/v1/charts')
uri.query = URI.encode_www_form({
start_hours: 48,
end_hours: 24
})
response = Net::HTTP.get(uri)
data = JSON.parse(response)
puts "Hourly activity (#{data['hourlyActivity'].length} hours):"
data['hourlyActivity'].each do |hour|
puts " #{hour['hour']}: #{hour['count']} spots"
end
puts "\nTop modes:"
data['byMode'].first(3).each do |mode|
puts " #{mode['mode']}: #{mode['count']}"
end
Fetch chart data for a time window in the past (e.g., yesterday)
Online Presence Endpoints
Real-time rosters of users currently connected to external CW repeaters. Vail ReRBN maintains continuous WebSocket connections to VBand and Vail Morse and exposes their user lists via plain REST.
Match an RBN spotted callsign against the live VBand/Vailmorse rosters to highlight operators who are both
on-air now and currently in a CW repeater room. The callsign field is normalized to upper-case
to make matching against RBN spots straightforward.
- VBand — all public channels except
LobbyandPractice Channel(the upstream server ignores user-list requests for those). Counts for every channel still appear in/online/rooms. - Vailmorse — the user list covers the
Generalroom only. Per-room counts for the rest of the network are in/online/rooms. We intentionally keep our monitor presence on Vailmorse to a single connection.
GET /online/vband
Users currently connected to VBand.
Example Response
{
"source": "vband",
"fetchedAt": "2026-05-14T13:52:00.000Z",
"users": [
{
"source": "vband",
"callsign": "WA3SCM",
"name": "wa3scm",
"room": "Channel 4",
"seenAt": "2026-05-14T13:51:42.000Z"
},
{
"source": "vband",
"callsign": "HAM-867 RX",
"name": "HAM-867 RX",
"room": "Channel 4",
"seenAt": "2026-05-14T13:51:38.000Z"
}
]
}
VBand uses honor-system free-text identifiers; there's no enforced callsign format. CW Hotline hardware appears with names
like HAM-867 RX. The callsign field is just the display name upper-cased.
GET /online/vailmorse
Users currently connected to Vail Morse.
Example Response
{
"source": "vailmorse",
"fetchedAt": "2026-05-14T13:52:00.000Z",
"users": [
{
"source": "vailmorse",
"callsign": "KE9BOS",
"name": "KE9BOS",
"room": "General",
"seenAt": "2026-05-14T13:51:50.000Z"
}
]
}
Vailmorse fills in anonNNNN if a client has no callsign set. The user list here covers the
General room only — see /online/rooms for per-room user counts
across the rest of the Vailmorse repeater network.
GET /online
Combined view across both sources. Each entry carries its source field so you can split client-side.
Example Response
{
"fetchedAt": "2026-05-14T13:52:00.000Z",
"users": [
{ "source": "vband", "callsign": "WA3SCM", "name": "wa3scm", "room": "Channel 4", "seenAt": "..." },
{ "source": "vailmorse", "callsign": "KE9BOS", "name": "KE9BOS", "room": "General", "seenAt": "..." }
]
}
GET /online/rooms
Per-source room user counts (the canonical values reported by VBand CLE / Vailmorse Rooms).
Example Response
{
"vband": [
{ "name": "Lobby", "users": 35 },
{ "name": "Practice Channel", "users": 17 },
{ "name": "Channel 4", "users": 5 }
],
"vailmorse": [
{ "name": "General", "users": 2 },
{ "name": "Decoder", "users": 1 }
]
}
For rooms where Vail ReRBN holds an active monitor connection (currently just Vailmorse General),
the upstream user count includes our own monitor. We subtract 1 from that count so the published value matches what
the user list shows. Rooms we don't have a connection to are reported verbatim.
Each (source, room) holds a snapshot that is replaced wholesale when a fresh user list arrives — users who left a room disappear immediately, not after a TTL. The 5-minute per-room TTL only matters when an upstream goes silent. VBand re-lists every 60 seconds; Vailmorse pushes updates on changes and any inbound frame from the connection keeps the snapshot valid. Poll every 30-60 seconds for live UIs.
GET /health
Health check endpoint. Returns server status, database info, and presence-ingestion health for the VBand and Vailmorse WebSocket clients.
Example Response
{
"status": "ok",
"database": "connected",
"databaseSizeMB": 45.2,
"timestamp": "2026-05-14T15:30:00Z",
"vband": {
"connected": true,
"lastMessageAt": "2026-05-14T15:29:58.000Z",
"reconnectCount": 0,
"usersTracked": 5,
"roomsTracked": 4
},
"vailmorse": {
"connected": true,
"lastMessageAt": "2026-05-14T15:29:55.000Z",
"reconnectCount": 0,
"usersTracked": 1,
"roomsTracked": 1
}
}
Each presence subsection reports the count of users currently tracked and the number of rooms with active snapshots.
Vailmorse's usersTracked / roomsTracked reflect General only (the only room we
enumerate users for); the full Vailmorse room list with counts is at /online/rooms.
GET /bands
Get list of amateur bands with frequency ranges.
Example Response
{
"bands": [
{
"name": "160m",
"minFreq": 1800,
"maxFreq": 2000
},
{
"name": "80m",
"minFreq": 3500,
"maxFreq": 4000
}
// ... more bands
]
}