Counter publishes NO AsyncAPI of its own. This document is DERIVED by API Evangelist from the AGPL-3.0 server source at https://github.com/ihucos/counter.dev/blob/master/backend/endpoints/dump.go and from a live capture of the public demo stream at https://counter.dev/dump?demo=1 on 2026-08-13. It describes the one real event surface Counter operates: the Server-Sent Events feed at GET /dump that the dashboard consumes, which pushes an initial archive snapshot and then a fresh aggregate dump as new visits arrive. Counter has NO webhooks — it never calls out to a consumer-supplied URL, and no webhook, callback or subscription surface exists anywhere in the source or the documentation. This SSE stream is the entirety of its event surface, and it is pull-only: the consumer opens the connection. Transport is one-way HTTP streaming (text/event-stream), not WebSocket, not MQTT, not Kafka. The server sets no write timeout for this route precisely so the connection can stay open (backend/lib/app.go).
View SpecView on GitHubWeb AnalyticsPrivacyOpen-SourceTrackingSelf-HostedAsyncAPIWebhooksEvents
Channels
dump
Server-Sent Events stream of one account's aggregated statistics. Authenticated by the `swa` session cookie, by a `user` + `token` query pair (read-only, sessionless), or opened anonymously against the public demo account with any non-empty `demo` parameter. Emits at most one dump per second — the handler throttles on `time.Since(lastDump) > 1s`.
Messages
✉
oldestArchiveDate
Oldest archived date
First frame. The earliest date for which archived data exists for this account, so the UI can bound its date picker.
✉
archive
Archive snapshot
Second frame. Two precomputed archive windows relative to the caller's UTC offset, keyed by their day offsets.
✉
dump
Live aggregate dump
The recurring frame. A full snapshot of the account's current aggregates, re-sent whenever new visits arrive (throttled to one per second).
✉
nouser
No subject resolved
Terminal frame. Sent when there is no session, no valid token pair and no demo flag. The server then closes the stream. Note this is a 200 response carrying an in-band failure, NOT a 403 — a consumer must inspect the frame type to detect that it is unauthenticated.
Servers
https
hosted
Hosted dashboard host. https://t.counter.dev serves the identical endpoint set (verified 2026-08-13); the split is conventional, not technical.
asyncapi: 3.0.0
info:
title: Counter Stats Stream
version: '1.0'
description: >-
Counter publishes NO AsyncAPI of its own. This document is DERIVED by API
Evangelist from the AGPL-3.0 server source at
https://github.com/ihucos/counter.dev/blob/master/backend/endpoints/dump.go
and from a live capture of the public demo stream at
https://counter.dev/dump?demo=1 on 2026-08-13. It describes the one real
event surface Counter operates: the Server-Sent Events feed at GET /dump that
the dashboard consumes, which pushes an initial archive snapshot and then a
fresh aggregate dump as new visits arrive.
Counter has NO webhooks — it never calls out to a consumer-supplied URL, and
no webhook, callback or subscription surface exists anywhere in the source or
the documentation. This SSE stream is the entirety of its event surface, and
it is pull-only: the consumer opens the connection.
Transport is one-way HTTP streaming (text/event-stream), not WebSocket, not
MQTT, not Kafka. The server sets no write timeout for this route precisely so
the connection can stay open (backend/lib/app.go).
license:
name: AGPL-3.0
url: https://github.com/ihucos/counter.dev/blob/master/LICENSE
contact:
name: Counter (ihucos)
url: https://github.com/ihucos/counter.dev
servers:
hosted:
host: counter.dev
protocol: https
description: >-
Hosted dashboard host. https://t.counter.dev serves the identical endpoint
set (verified 2026-08-13); the split is conventional, not technical.
selfhosted:
host: '{host}'
protocol: https
description: A self-hosted Counter deployment.
variables:
host:
default: localhost:8080
description: The operator's own Counter host.
channels:
dump:
address: /dump
title: Aggregated stats stream
description: >-
Server-Sent Events stream of one account's aggregated statistics.
Authenticated by the `swa` session cookie, by a `user` + `token` query pair
(read-only, sessionless), or opened anonymously against the public demo
account with any non-empty `demo` parameter. Emits at most one dump per
second — the handler throttles on `time.Since(lastDump) > 1s`.
messages:
oldestArchiveDate:
$ref: '#/components/messages/oldestArchiveDate'
archive:
$ref: '#/components/messages/archive'
dump:
$ref: '#/components/messages/dump'
nouser:
$ref: '#/components/messages/nouser'
operations:
receiveStats:
action: receive
channel:
$ref: '#/channels/dump'
title: Consume the stats stream
description: >-
Open GET /dump and read typed SSE frames. The server sends
`oldest-archive-date`, then `archive`, then an initial `dump`, then a
further `dump` each time the account's Redis signal fires and at least one
second has elapsed. If no subject can be resolved the server sends a single
`nouser` frame and closes.
messages:
- $ref: '#/channels/dump/messages/oldestArchiveDate'
- $ref: '#/channels/dump/messages/archive'
- $ref: '#/channels/dump/messages/dump'
- $ref: '#/channels/dump/messages/nouser'
components:
messages:
oldestArchiveDate:
name: oldest-archive-date
title: Oldest archived date
summary: >-
First frame. The earliest date for which archived data exists for this
account, so the UI can bound its date picker.
contentType: text/event-stream
payload:
type: object
required: [type, payload]
properties:
type:
type: string
const: oldest-archive-date
payload:
type: string
description: Date as YYYY-MM-DD, or null when no archive exists.
examples:
- name: demoAccount
summary: Observed live on https://counter.dev/dump?demo=1 (2026-08-13)
payload:
type: oldest-archive-date
payload: '2022-09-20'
archive:
name: archive
title: Archive snapshot
summary: >-
Second frame. Two precomputed archive windows relative to the caller's
UTC offset, keyed by their day offsets.
contentType: text/event-stream
payload:
type: object
required: [type, payload]
properties:
type:
type: string
const: archive
payload:
type: object
description: >-
Map of window key to archive result. Keys observed are "-7:-2"
(last seven days ending two days ago) and "-30:-2".
additionalProperties:
type: object
dump:
name: dump
title: Live aggregate dump
summary: >-
The recurring frame. A full snapshot of the account's current aggregates,
re-sent whenever new visits arrive (throttled to one per second).
contentType: text/event-stream
payload:
type: object
required: [type, payload]
properties:
type:
type: string
const: dump
payload:
type: object
properties:
sites:
type: object
description: Map of site id to that site's aggregates.
additionalProperties:
type: object
properties:
count:
type: integer
logs:
type: object
additionalProperties:
type: integer
visits:
type: object
properties:
day: {type: object}
yesterday: {type: object}
month: {type: object}
year: {type: object}
all: {type: object}
user:
type: object
properties:
id: {type: string}
token:
type: string
description: >-
The account's read-only share token. NOTE — the stream
returns the caller's own token in every dump frame.
uuid: {type: string}
isSubscribed: {type: boolean}
prefs:
type: object
additionalProperties: {type: string}
meta:
type: object
description: >-
Empty for a normal session. Carries {"demo":"1"} on the demo
account and {"sessionless":"1"} when authenticated by the
user+token pair.
additionalProperties: {type: string}
nouser:
name: nouser
title: No subject resolved
summary: >-
Terminal frame. Sent when there is no session, no valid token pair and no
demo flag. The server then closes the stream. Note this is a 200 response
carrying an in-band failure, NOT a 403 — a consumer must inspect the
frame type to detect that it is unauthenticated.
contentType: text/event-stream
payload:
type: object
required: [type, payload]
properties:
type:
type: string
const: nouser
payload:
type: 'null'
securitySchemes:
apiToken:
type: httpApiKey
in: query
name: token
description: >-
Read-only account token, paired with the `user` query parameter. See
authentication/counter-dev-authentication.yml.
sessionCookie:
type: httpApiKey
in: cookie
name: swa
description: >-
Session cookie set by POST /login and POST /register. The source names it
`swa` (backend/lib/ctx.go).