Event API & webhooks

GopherTrunk publishes every decode event on an internal bus and exposes it four ways: a Server-Sent Events stream, a WebSocket stream, an optional append-only JSONL event log, and outbound webhook POSTs. The three stream/log surfaces all carry the same event envelope; the webhooks are a separate, flatter push shape documented below.

This page is the stable JSON contract downstream consumers (Grafana, Prometheus exporters, dashboards) build against.

Transports

Server-Sent Events — GET /api/v1/events

A long-lived text/event-stream. On connect the server sends one comment line (: gophertrunk events stream) and then one SSE record per event:

event: call.start
data: {"kind":"call.start","timestamp":"2026-08-03T17:00:00Z","payload":{ ... }}

The event: line carries the event kind; browsers can dispatch per kind with addEventListener("grant", …). The data: payload is the JSON envelope. A payload containing embedded newlines is split across multiple data: lines per the SSE spec (reassemble by concatenation).

curl -N http://localhost:8080/api/v1/events

There is no server-side filtering — every subscriber receives every kind and filters client-side. There is no periodic keepalive on SSE (only the initial comment line); use the WebSocket transport if you need a ping.

WebSocket — GET /api/v1/events/ws

The same stream as one JSON text frame per event (identical body to the SSE data: payload). The connection is server→client only; any frames a client sends are discarded. The server sends a WebSocket ping every 30 s so idle connections and proxies stay open. The browser console uses this transport (the EventSource API can’t set headers).

websocat ws://localhost:8080/api/v1/events/ws

Event log (JSONL)

When log.event_log.enabled is set, GopherTrunk mirrors every bus event to an append-only JSONL file in the exact same envelope the streams emit — an offline/replayable copy of the live feed. See configuration.

Webhooks

Outbound POST sinks that push a completed call (or a decoded grant) to a URL you configure, rather than a stream you subscribe to. See Webhooks.

Event envelope

Every streamed event (SSE data:, WS frame, and JSONL line) is this envelope:

field type notes
kind string the event kind (equals the event: line on SSE)
timestamp RFC3339 string bus publish time
payload object kind-specific body — see payload schema

timestamp and all time.Time payload fields serialize as RFC3339. Non-finite floats (±Inf/NaN) are scrubbed from payloads before marshaling so a marginal metric can’t break the stream.

Event kinds

Payloads come in two tiers:

  1. Stable DTOs — ten kinds have a curated, versioned JSON payload defined in the API package. These are the contract you should build against; their field names will not change without notice. Documented in full below.

  2. Passthrough kinds — every other kind serializes its underlying internal payload struct directly. These are useful but less stable: their JSON tracks the internal type, and a few (e.g. channel.power, decode.error) have no JSON tags and therefore emit Go-capitalized field names. Treat them as best-effort. For a passthrough kind’s current shape, read the field off a live stream rather than pinning it.

Stable kinds: grant, call.start, call.end, call.encryption, affiliation, registration, unit.request, patch, dmr.grant.observed, dmr.bandplan.learned.

Passthrough kinds include: sdr.attached, sdr.detached, cc.locked, cc.lost, call.complete, call.segment, call.source, call.release, call.talker, grant.unserved, tone.alert, decode.error, error, talker.alias, location, site.update, patch, audio.state, channel.power, bookmark.created, bookmark.updated, bookmark.deleted, pager.message, aprs.packet, ais.message, dsc.message, adsb.aircraft, mdc1200.message, fleetsync.message, acars.message, m17.linksetup, lora.frame, cchunt.progress, cchunt.failed, hunt.progress, hunt.candidate, hunt.done, unit.request, dmr.grant.observed, dmr.bandplan.learned, and the P25 unit-signalling kinds unit.status, unit.message, call.alert, unit.ack, unit.queued, unit.deny, unit.function, unit.monitor (snake_case payloads, see below), and call.transcript (the transcription backend’s text for a finished recording: system, protocol, group_id, source_id, frequency_hz, device_serial, call_started_at, segment, audio_path, text, at).

The in-call passthrough kinds call.source, call.talker, call.release and call.segment now carry snake_case field names matching the grant DTO (system, group_id, source_id, frequency_hz on call.source, at, …) so activity feeds can render them with the same formatter as grant. They remain passthrough (best-effort) kinds; earlier releases emitted Go-capitalized field names for them.

P25 unit signalling (passthrough, snake_case)

Decoded from the control channel’s per-radio TSBKs (layouts pinned against SDRTrunk’s field positions and reason tables):

Kind TSBK Payload fields
unit.status STS_UPDT 0x18 system, protocol, source_id, target_id, unit_status, user_status, at
unit.message MSG_UPDT 0x1C system, protocol, source_id, group_id, message (16-bit), at
call.alert CALL_ALRT 0x1F system, protocol, source_id, target_id, at — a page from one radio to another
unit.ack ACK_RSP_FNE 0x20 response: "ack", target_id, service_type, service_name, source_id or wacn + system_id (extended form)
unit.queued QUE_RSP 0x21 response: "queued", target_id, service_type, service_name, reason, reason_name, additional_info_hex
unit.deny DENY_RSP 0x27 response: "deny", same fields as queued; reason_name from the TIA deny-reason table
unit.function EXT_FNCT_CMD 0x24 function, function_name (radio check / inhibit / uninhibit / detach + acks, regroup create / cancel), arguments, source_id (commanding unit), target_id
unit.monitor RAD_MON_CMD 0x1D source_id, target_id, tx_multiplier

All eight are also watchable by alerts: rules (on: [unit.deny], on: [unit.function] for an inhibit, …).

Stable payload schema

Field types are the JSON types the Go structs marshal to. omitempty fields are absent (not zero) when unset.

grant

Fires when the control channel decodes a voice/data channel grant.

field type notes
system string  
protocol string  
group_id number talkgroup — or the destination unit RID when individual is true
source_id number source RID as the grant carried it (0 if none; not voice-backfilled)
frequency_hz number  
channel_id number omitempty
channel_number number omitempty
rfss_id number omitempty — P25 site identity
site_id number omitempty
nac number omitempty — P25 network access code; on Phase 2 the NSB colour code. Absent on non-P25 grants (read “absent” as “not a P25 grant”, never as a P25 grant that lost its NAC)
timeslot number omitempty — 1-based (DMR Tier III)
encrypted bool omitempty
emergency bool omitempty
data_call bool omitempty
individual bool omitempty — group_id is a unit, not a talkgroup
algorithm_id number omitempty
key_id number omitempty

call.start

field type notes
grant object a grant payload
talkgroup object omitempty — see Talkgroup
device_serial string the SDR that carried the call
started_at RFC3339 string  

call.end

field type notes
grant object a grant payload
talkgroup object omitempty — see Talkgroup
device_serial string  
started_at RFC3339 string  
ended_at RFC3339 string  
duration_ms number call length in ms (ended − started); 0 when timestamps aren’t sane (e.g. a watchdog teardown). Lets an SSE/WS-only consumer read duration without pairing back to call.start
reason string why the call ended

call.end is the stream’s completion event. call.complete also exists but is a passthrough kind; prefer call.end for a stable duration/reason.

call.complete announces one finished recording FILE. A call recorded in per-transmission grouping (one file per over) emits several: each carries its own StartedAt/EndedAt (the over’s span), the call’s start in CallStartedAt, and a 0-based Segment index. GET /api/v1/calls/{id}/audio plays every segment of the call back to back.

Catching up on finished recordings: GET /api/v1/recordings

The event stream only reaches a consumer that is connected. To pick up what finished while it was down (an archiver, a transcription pipeline, a restarted webhook receiver), read the finalized-recording feed, which needs storage.path:

GET /api/v1/recordings?after=<id>&limit=<n>      (limit default 100, max 1000)
{
  "recordings": [
    {
      "id": 4182,
      "call_id": 90311,
      "segment": 0,
      "started_at": "2026-10-25T14:02:11.204Z",
      "ended_at": "2026-10-25T14:02:15.880Z",
      "format": "wav",
      "partial": false,
      "audio_url": "/api/v1/recordings/4182/audio",
      "call": { "id": 90311, "system": "scanner", "protocol": "fm-conv", "frequency_hz": 460100000,
                "device_serial": "wb:00000008:conv:460100000", "talkgroup_alpha": "Fire Dispatch",
                "end_reason": "normal", "duration_ms": 4676, "...": "the /calls/history row" }
    }
  ],
  "next_after": 4182
}
  • An entry appears only once the recorder has finished its file, so a file still being written is never listed. id is assigned at that moment, so the feed is in completion order: a long call that started first but finished last is listed after the shorter calls, not skipped. (A cursor on the call row’s id would skip it, because that id is assigned at call start.)
  • Keep the last id you processed and pass it as after; next_after is the value for the next request. Re-reading from an older id returns the same entries with the same ids, so duplicates are recognisable: key on id.
  • A call recorded in per-transmission grouping has one entry per file (segment 0, 1, …), all with the same call_id.
  • audio_url serves the original file as recorded (WAV, FLAC or MP3, with range support); /api/v1/calls/{id}/audio instead joins a call’s segments into one WAV for playback.
  • partial: true means IQ was lost while the call was recorded (dropped to a lagging voice tap, or by the SDR’s driver), so the audio has gaps; iq_lost_chunks says how many IQ chunks were lost. The file is finished and playable, but it is not a complete capture: an archive or a transcription pipeline should keep that distinction. The same count is on the call.complete event (IQLostChunks) and in the recording’s .json sidecar (iq_lost_chunks, omitted when nothing was lost). It counts the IQ lost while that file was being written, so with transmission grouping each over of a call is marked on its own.
  • Retention deletes old recordings; an entry whose call row was removed comes back without call, and its audio returns 404.

Outbound feeds (webhooks, broadcast backends) run on bounded queues off the SDR path; their delivery counters are on /metrics as gophertrunk_delivery_* (see the Hardening guide).

call.encryption

Emitted when a call is observed to be encrypted.

field type notes
device_serial string  
system string omitempty
protocol string omitempty
group_id number omitempty
algorithm_id number  
key_id number  
at RFC3339 string  

affiliation

P25 group affiliation response.

field type notes
system string  
protocol string  
source_id number affiliating unit
group_id number talkgroup
announcement_group number omitempty
response string accepted / denied / refused / failed
rfss_id number omitempty
site_id number omitempty
nac number omitempty

registration

P25 unit registration response.

field type notes
system string  
protocol string  
source_id number registering unit
wacn number  
system_id number  
response string accepted / denied / refused / failed
rfss_id number omitempty
site_id number omitempty
nac number omitempty

unit.request

Unit-to-unit (private call) request.

field type notes
system string  
protocol string  
source_id number  
target_id number  
service_options number omitempty

patch

Talkgroup patch / supergroup activation or cancellation.

field type notes
system string  
protocol string  
super_group number the patch supergroup
members array of number member talkgroups
vendor string omitempty
add bool true = activate, false = cancel
at RFC3339 string  

dmr.grant.observed

A DMR Tier III channel grant observed on the control channel.

field type notes
system string  
color_code number  
lcn number logical channel number
timeslot number raw CSBK value: 0 = TS1, 1 = TS2
group_id number  
source_id number  
cc_freq_hz number control-channel frequency
at RFC3339 string  

dmr.bandplan.learned

The learned DMR band plan (LCN → frequency mapping).

field type notes
system string  
base_hz number omitempty
spacing_hz number omitempty
offset number omitempty
table array omitempty — each { "lcn": number, "freq_hz": number }
num_pairs number  
confidence number  
residual_hz number omitempty

Talkgroup object

Nested in call.start / call.end as talkgroup (from the talkgroup alias table). Present only when the talkgroup is known.

field type notes
id number  
alpha_tag string  
description string omitempty
tag string omitempty
group string omitempty
mode string omitempty
priority number omitempty
lockout bool omitempty
scan bool  
stream bool  
record bool  
mute bool  
icon string omitempty
discovered bool true if auto-discovered rather than from the alias file

Webhooks

Two independent outbound sinks. Both POST JSON, set Content-Type: application/json, and set an Authorization header verbatim from auth_header when configured. Neither is served over the SSE/WS stream.

Per-call webhook

Fires once per completed call (after the recording is flushed). Configured under broadcast.webhook. Retries up to 3 times (4 attempts total) with exponential backoff starting at 2 s; a non-2xx response counts as a failure.

Payload:

field type notes
event string always "call"
system string  
protocol string  
call_type string group / unit / data
talkgroup number  
talkgroup_label string omitempty
source number omitempty — source RID at end of call (voice-backfilled)
frequency_hz number  
channel_id number omitempty
rfss_id number omitempty
site_id number omitempty
nac number omitempty
timeslot number omitempty
encrypted bool  
algorithm_id number omitempty
key_id number omitempty
emergency bool  
patched_groups array of number omitempty
started_at RFC3339 string in the configured display timezone
ended_at RFC3339 string  
duration_ms number  
audio_filename string omitempty
audio_format string omitempty — mp3 when audio is embedded
audio_base64 string omitempty — base64 MP3, only when include_audio: true

Per-grant webhook

Fires once per decoded control-channel grant — the push form of GET /api/v1/grants and the grant stream event. Configured under broadcast.grant_webhook. Uses a bounded internal queue (depth 256; overflow drops the newest grant rather than blocking the decoder), retries up to 3 times with 2 s exponential backoff, 30 s timeout per attempt.

The payload is the grant schema field-for-field, plus:

field type notes
event string always "grant"
at RFC3339 string decode time, in the display timezone

The grant event, GET /api/v1/grants, and this webhook share field names deliberately. Its source_id is what the control channel saw at grant time (never voice-backfilled) — that is the difference from the per-call webhook’s source.

Configuration

The SSE and WebSocket streams run whenever the HTTP API is enabled — there is no per-stream toggle.

api:
  http_addr: "127.0.0.1:8080"   # enables the HTTP server (SSE + WS live here)
  cors:
    allowed_origins: ["https://dash.example.com"]  # also gates the WS Origin check
  # api.auth gates MUTATIONS only — it does NOT authenticate SSE/WS (see caveats)

log:
  event_log:
    enabled: true               # mirror every event to a JSONL file
    path: "/var/lib/gophertrunk/events.jsonl"
    max_size_mb: 16

broadcast:
  webhook:                       # per completed call
    - enabled: true
      name: "call-hook"
      url: "https://example.com/hooks/call"
      auth_header: "Bearer <token>"   # optional
      include_audio: false            # embed base64 MP3 when true
      systems: []                     # empty = all systems
  grant_webhook:                 # per decoded grant
    - enabled: true
      name: "grant-hook"
      url: "https://example.com/hooks/grant"
      auth_header: "Bearer <token>"
      systems: []

Reliability & caveats

  • The event streams are unauthenticated. api.auth gates mutating endpoints only; GET /api/v1/events and /events/ws are open to anyone who can reach the API. If you expose the API beyond localhost, front it with a reverse proxy that enforces auth/TLS. See Hardening & operations.
  • No server-side filtering. Every subscriber receives every kind; filter client-side (by the kind field or the SSE event: name). There are no ?system= / ?kind= query parameters.
  • Slow subscribers drop events. Each subscriber has a bounded buffer; a client that can’t keep up has events dropped rather than blocking the decoder, so a stream is not a guaranteed-delivery log. For a complete record use the JSONL event log or a webhook (which retries).
  • Two stability tiers. Only the ten stable DTOs are a versioned contract. Passthrough kinds mirror internal structs and may change.
  • Timezones. Envelope timestamp and stream DTO time fields are RFC3339 in UTC as Go marshals them; webhook at / started_at / ended_at are RFC3339 rendered in the operator’s configured display timezone.