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.
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
Recreate an event from a full-event JSON export.
{ "payload": [ /* the export */ ], "admin_key": "optional" }
Always 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" }] }.
Gated by EZFD_ADMIN_KEY when it is set, matching event creation.
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.
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, so a client can tell when its own clock disagrees.
{ "app_time": "2026-06-27T18:04:11.204Z", "db_time": "2026-06-27T18:04:11.207Z" }
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.
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.
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.