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, restore and merge if the server sets it.
See Configuration.
Errors
A request the server cannot accept gets a 400 with { "error": "..." }
naming the problem: a body that is not a JSON object, an id that is not a
UUID, a callsign that is not one, a band outside the app's list, a mode other
than PH, CW or DIG. These used to reach the database and come back as a
500, which the offline queue retries forever. A 4xx means the server will
never accept that request as sent; a 5xx or no answer is worth retrying.
Free text (comments, names, notes) is trimmed to a fixed length rather than
refused, so an over-long comment never costs the contact it is attached to.
POST /api/import/adif refuses a body over 20 MB, and
POST /api/import/event one over 50 MB, with a 413.
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. Compared in constant time |
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. The logging windows send the rig's frequency when it agrees with the logged band |
replay |
Marks a QSO replayed from the offline queue |
age_ms |
How long ago the contact was logged, by the sender's own clock. The server dates it NOW() - age_ms, capped at seven days |
Duplicate status is computed server-side from the event's dupe rule, for the whole (call, band, mode) group, so a backdated contact that lands before an existing one makes the later one the dupe. Duplicates are logged and flagged, never rejected. A free-text field over 500 characters is trimmed.
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 under the event's dupe rule for both the group the contact left and the group it joined, so moving the first of two contacts to another band releases the second.
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. If it was the first of two contacts with the
same station on that band and mode, the second stops being a dupe.
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": "N0CALL" }. Dupe
status is re-evaluated, so a restored first contact makes the later one a dupe
again.
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. 400 if eventId is not a UUID, 404 if no event has
it. Three event types:
| Event | Payload |
|---|---|
qso |
{ op, record } where op is INSERT, UPDATE or DELETE |
reservation |
Same shape, for checkouts |
presence |
{ op, op_call, station, band, mode } when a window arrives, changes band or mode, or goes QRT |
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.
A presence heartbeat that changes nothing is not announced, so a window that
closes without going QRT is noticed only by the 90-second staleness in
GET /api/presence. Screens treat presence as a signal to refetch and sweep
once a minute for the rest.
A row too large for a PostgreSQL notification (8,000 bytes) is fetched by the server and delivered whole, so a client never sees a different shape.
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 }. The file goes
in as one transaction, so a failure leaves nothing half-imported. Each
record's FREQ and MODE are kept, so the export writes them back out.
skipped counts records with no recognisable band or callsign;
already_present includes a contact that appears twice in the file itself.
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. Lookups give up after five seconds, are cached per event for half an hour, and sign in again if QRZ ended the session early (it does when the same account signs in elsewhere).
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.