HTTP API

All endpoints live under /api. Everything is JSON except the export and download routes, which return files, and the real-time route, which is an event stream.

There is no authentication. Access control is the join code, plus EZFD_ADMIN_KEY on event creation if the server sets it. See Configuration.

Events

POST /api/events

Create an event. Returns { id, join_code }, plus warnings if an optional callsign database failed to download.

Field Notes
club_name, club_call Required
event_type FD, WFD or SES; defaults to FD
class, arrl_section Required for contests; optional for SES
power HIGH, LOW, QRP
event_year Defaults to the current year
location
qrz_username, qrz_password Password encrypted before storage
admin_key Required if the server sets EZFD_ADMIN_KEY
use_call_history, use_master_callsign_file Forced off for SES and non-applicable respectively
starts_at, ends_at SES only, ISO 8601
ses_description, ses_qsl_info SES only
slot_enforcement SOFT (default) or HARD
slot_minutes Default checkout length, default 120
dupe_rule EVENT, DAY or NONE
require_operator_approval SES only, default false

Callsign database downloads are best-effort and time-limited. A failure returns a warning and still creates the event.

403 if the admin key is required and wrong.

GET /api/events/{code}

Event by join code. Never returns qrz_password.

PATCH /api/events/{code}/bonuses

Replace the bonus object. Body is the bonuses map itself.

QSOs

POST /api/qso

Log a QSO. Returns the created row, 201.

Field Notes
event_id or join_code One is required
callsign, band, mode Required
rcvd_class, rcvd_section Contest exchange; class ignored for SES
operator_call, station_number
rst_sent, rst_rcvd, rcvd_name, rcvd_qth, rcvd_grid, comment Special event exchange
adif_mode, freq_khz Optional detail for export
replay Marks a QSO replayed from the offline queue

Duplicate status is computed server-side from the event's dupe rule. Duplicates are logged and flagged, never rejected.

On a special event the server may add slot_warning to the response — the QSO was logged, but on a band and mode the operator hasn't checked out.

403 if roster approval is required and the operator isn't approved. 409 if enforcement is HARD and the operator doesn't hold the slot.

replay: true bypasses both, unconditionally. A contact that already happened on the air must not be dropped because the network blipped.

GET /api/qso?event_id=

Every QSO for an event, newest first.

PATCH /api/qso/{id}

Edit callsign, band, mode or exchange. Duplicate status is re-evaluated.

DELETE /api/qso/{id}?operator_call=

Soft delete — the row is marked, not removed. It leaves the live log, the ADIF and Cabrillo exports, scoring and dupe checking immediately; the full-event JSON backup keeps it. operator_call records who claimed to delete it. Deleting an already-deleted contact returns 200.

POST /api/qso/{id}

Restore a soft-deleted contact. Body { "operator_call": "W0AAA" }.

GET /api/qso?event_id=&deleted=1

The soft-deleted contacts for an event, most recently deleted first. Without deleted=1 only live contacts are returned.

Special events

GET /api/ses/reservations?event_id=

Current and upcoming checkouts, within the next 48 hours, excluding released ones. Each row carries starts_at and ends_at decomposed from the stored range.

POST /api/ses/reservations

Check out a band and mode. Returns the reservation, 201.

Field Notes
event_id or join_code Required
op_call, band, mode Required
minutes Defaults to the event's slot length
starts_at Defaults to now; a future value books ahead
planned_freq, note Free text

409 if it would overlap an existing checkout, with holder naming who has it and until when. 400 for an unknown band or mode, or a non-SES event.

PATCH /api/ses/reservations/{id}

Field Notes
action extend or release
op_call Must match the holder
minutes For extend, default 15

403 if the slot belongs to someone else. 409 if an extension would run into the next holder's window.

GET /api/ses/operators?event_id=

The roster.

POST /api/ses/operators

Create or update a roster entry. Upserts on (event_id, op_call), and only overwrites fields you actually send — saving just a grid won't blank a name.

On an event requiring approval, a newly-created entry starts unapproved. Existing entries keep whatever approval state they have; re-saving your grid never silently re-approves you.

Presence

GET /api/presence?event_id=

Operators active in the last 90 seconds.

POST /api/presence

Publish or refresh your band and mode. Upserts on (event_id, op_call, station) — keyed per radio, so one operator running two rigs from two windows holds two rows rather than overwriting themselves.

Field Notes
event_id, op_call, band, mode Required
station Which radio. Defaults to 1

DELETE /api/presence

Go QRT. Takes event_id, op_call and station — shutting one radio down leaves the operator's other radio on the air.

Real-time

GET /api/realtime/{eventId}

Server-Sent Events. Two event types:

Event Payload
qso { op, record } where op is INSERT, UPDATE or DELETE
reservation Same shape, for checkouts

A keepalive comment every 25 seconds keeps proxies from timing the stream out.

The reservation payload carries the raw range column rather than decomposed timestamps — treat it as a signal to refetch, not as data. The logging window's coordination panel and the operating position picker both do exactly that.

There is no presence event. A logging window reports itself over HTTP rather than through the database, so who is on air is polled by whatever displays it.

Import and export

GET /api/export/{code}

ADIF by default; ?format=cabrillo for Cabrillo; ?format=json for a full backup.

?format=json returns the whole event — settings, bonuses, every QSO, the SES roster and the checkout history — as a restorable backup, and is what POST /api/import/event consumes. QRZ credentials are excluded by construction. The op/from/to filters apply to ADIF only; a filtered portability export would restore as a partial event.

Parameter Effect
format adif (default), cabrillo or json
op Only that operator's QSOs
from, to Restrict to a UTC window

Duplicates are excluded and records are chronological. Filenames reflect the filters, so per-operator exports don't collide.

400 for format=cabrillo on a special event — there is no contest to submit to.

POST /api/import/event

Bring a full-event JSON export into this instance. Two modes.

{ "payload": [ /* the export */ ], "admin_key": "optional" }

Restore (no query string) creates a new event with a fresh join code — never overwrites or merges — so importing is safe to try, and safe to try twice. Returns { "imported": [{ "orig_code", "new_code", "qso_count" }] } with 201.

Merge (?merge_into=CODE) reconciles the export into an event that already exists here, for when one activation ran in two places at once. Takes exactly one event, not a whole-database export. Returns 200 and a report:

{ "merged": {
  "target_join_code": "AB12CD", "origin_event_id": "…", "origin_matched": true,
  "qsos_added": 12, "already_present_by_id": 40, "already_present_by_time": 3,
  "skipped_deleted_here": 1, "conflicts": [], "roster_added": 2,
  "roster_already_present": 3, "reservations_added": 6,
  "dupe_flags_changed": 4, "settings_differ": ["bonuses"]
} }
Field Meaning
qsos_added Contacts this instance did not have
already_present_by_id Recognised by preserved QSO id
already_present_by_time Logged independently in both, matched within ±2 minutes
skipped_deleted_here Deleted here, still live there — the deletion stands
conflicts Edited on both sides; reported, never resolved
dupe_flags_changed Rows whose is_dupe the recompute corrected
settings_differ Event settings that disagree; the target's are kept

400 if the export is a different activation — pass "allow_different_origin": true to override, which genuinely combines two events. 404 if no event here has that join code.

Both modes are gated by EZFD_ADMIN_KEY when it is set. See Administration → Merging two instances of one event for when you would.

POST /api/import/adif

Field Notes
event_id, adif Required
operator_call, station_number Attributed to the imported QSOs

Returns { imported, dupes, already_present, skipped, total }.

Idempotent: a record matching an existing QSO on callsign, band, mode and a ±2 minute window is skipped rather than inserted. A contact you deleted counts as absent here and comes back — importing a file is a deliberate act. The event merge above goes the other way, because it is bulk and automatic.

Lookups

GET /api/qrz?callsign=&event_id=

QRZ lookup using the event's stored credentials. Returns name, state, country and grid where available.

GET /api/callhistory?callsign=&event_id=

The station's usual class and section from the N1MM file, plus known_master indicating whether the callsign appears in MASTER.SCP.

Server

GET /api/time

The server's current time, what is holding its clock, and what the connected operators' devices make of it.

{
  "app_time": "2026-06-27T18:04:11.204Z",
  "db_time":  "2026-06-27T18:04:11.207Z",
  "clock": {
    "source": "gps",
    "synchronized": false,
    "last_sync": "2026-06-27T18:03:42.000Z",
    "age_ms": 29204,
    "stale": false,
    "unaccounted_for": false,
    "rtc": true
  },
  "quorum": {
    "devices": 11, "agreeing": 9,
    "median_skew_ms": -240000, "server_is_wrong": true
  }
}

db_time is PostgreSQL's clock — the one that actually stamps QSOs — and is null if the database can't be reached, which is not treated as an error. The two are reported separately because the app process and the database need not be on the same host.

clock describes what is keeping the server's time. source is one of gps, chrony, timesyncd, rtc or null; last_sync is when something last disciplined the clock, and age_ms how long ago that was. stale means that age is past thirty days. unaccounted_for means no NTP, no reference and no RTC — the time it is showing came from its last shutdown.

Every field here can be null, and null means nothing could be asked, not that the answer is no. A container with no timedatectl reports nulls, and rendering those as a warning is the exact false alarm this replaced: it asked whether NTP was synchronised, which on an offline field server is "no" permanently and by design, so it fired hardest at the operator who had fitted an RTC and done everything right. Both stale and unaccounted_for therefore require positive evidence and are false when the server could not look.

quorum is the connected devices' opinion. server_is_wrong is only true with at least three distinct devices reporting and a two-thirds majority agreeing in the same direction; below that there is a disagreement but no verdict, and the UI says so rather than blaming either side.

This doesn't change who is authoritative: QSOs are still stamped by the server. It exists so a wrong server clock is visible rather than silent. Clients should halve the round-trip time when comparing, so a slow link doesn't read as skew.

POST /api/time

The same payload, and a chance to contribute an observation to the quorum.

{ "skew_ms": -240000, "device_id": "8f14e45fceea167a" }

skew_ms is the server's clock minus this device's, as the client computed it — only the client can halve the round trip, so the server cannot derive this from a timestamp it is handed. device_id is a stable per-device id, not per-window: the CW popout is a second document on the same machine with the same clock, and counting it twice would let one operator carry double weight.

Observations are held in memory for fifteen minutes and only the newest from each device counts, so a browser left open overnight cannot outvote the field. A malformed body, an unparseable skew_ms or a missing device_id is ignored rather than rejected — the caller still wants the time — and a skew_ms beyond 24 hours is discarded as a device with no clock at all rather than evidence about the server.

Downloads

GET /api/download/wsjtx-bridge

The relay script.

GET /api/download/relay?join_code=&operator=&station=&api_url=

A Windows .bat wrapper with the event details filled in.