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
- Event envelope
- Event kinds
- Stable payload schema
- Webhooks
- Configuration
- Reliability & caveats
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:
-
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.
-
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, m17.linksetup, lora.frame, cchunt.progress,
cchunt.failed, hunt.progress, hunt.candidate, hunt.done,
unit.request, dmr.grant.observed, dmr.bandplan.learned.
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.endis the stream’s completion event.call.completealso exists but is a passthrough kind; prefercall.endfor a stable duration/reason.
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.authgates mutating endpoints only;GET /api/v1/eventsand/events/wsare 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
kindfield or the SSEevent: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
timestampand stream DTO time fields are RFC3339 in UTC as Go marshals them; webhookat/started_at/ended_atare RFC3339 rendered in the operator’s configured display timezone.