Widgets Quickstart
Widgets are compact tiles pinned below the server header in the sidebar. An external agent POSTs pre-defined rows — counters, status indicators, text, and progress bars — and every member currently viewing that server sees the tile update live via SSE.
Typical uses: stream-viewer count, build status, on-call rotation, server uptime, backup lag.
Create a widget
- Open the developer portal → Servers → pick a server.
- In the Widget panel, enter a name and (optional) description, submit.
- Copy the one-time ingest URL from the reveal banner. Regenerating rotates the token immediately — the old URL stops working.
The ingest URL looks like:
https://chattr.example.com/widgets/:appId/:token
Send a snapshot
POST /widgets/:appId/:token
Content-Type: application/json
{
"rows": [
{ "kind": "counter", "label": "Online", "value": 1247 },
{ "kind": "status", "label": "API", "state": "ok", "text": "200 OK" },
{ "kind": "progress", "label": "Disk", "current": 42, "max": 128 },
{ "kind": "text", "label": "Version", "value": "1.4.2-beta" }
]
}
Each POST replaces the previous snapshot. There is no history — only the latest rows are stored and rendered.
Response on success:
{
"snapshot": {
"updatedAt": "2026-01-15T13:42:07.012Z",
"rows": [ /* echoed back */ ]
}
}
Row kinds
| Kind | Shape | Rendered as |
|---|---|---|
counter |
{ label, value: number } |
label on the left, numeric value on the right (locale-formatted once ≥ 1000) |
status |
{ label, state: "ok" | "warn" | "err" | "idle", text?: string } |
Coloured dot + state chip + optional text |
text |
{ label, value: string } |
label and short text value, truncated to 120 chars |
progress |
{ label, current: number, max: number } |
Label, current / max, and a bar filled to current / max |
Limits
| Constraint | Value |
|---|---|
| Rows per snapshot | 1 – 6 |
label length |
1 – 40 chars |
text/value length |
≤ 120 chars |
progress.max |
Must be finite, ≥ 0 |
| POST body | ≤ 32 KB |
| Rate limit | 30 POSTs / minute / IP |
An invalid row kind, an over-length field, or missing required keys → 400 with a descriptive error (for example row_2:label_too_long). The stored snapshot is not touched when validation fails.
Pinning and visibility
- Pin: a server admin picks one widget from the server Widgets modal (sidebar header button) to display it under the server meta. Only one widget is pinned per server.
- Visibility: admins can hide a widget. Hidden widgets are invisible to non-admins and reject ingest with
403 widget_invisible. Hiding a currently-pinned widget auto-unpins it. - Deleting the widget app auto-unpins it and drops the snapshot.
Live updates
Every successful POST publishes a workspace SSE event to every member viewing the server. Clients with the pinned widget mounted refresh the tile in place — no full sidebar re-render. The modal list also refreshes for anyone who has it open.
Code examples
cURL
curl -X POST "https://chattr.example.com/widgets/APP_ID/WIDGET_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{ "kind": "counter", "label": "Online users", "value": 42 },
{ "kind": "status", "label": "API", "state": "ok", "text": "200 OK" }
]
}'
Node.js
const WIDGET_URL = "https://chattr.example.com/widgets/APP_ID/WIDGET_TOKEN";
async function push(rows) {
const res = await fetch(WIDGET_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows }),
});
if (!res.ok) throw new Error("widget ingest failed: " + res.status);
}
// Called from your monitoring loop, e.g. every 30 seconds.
await push([
{ kind: "counter", label: "Online users", value: await countOnline() },
{ kind: "status", label: "Build", state: "warn", text: "queue depth 3" },
]);
Python
import requests
WIDGET_URL = "https://chattr.example.com/widgets/APP_ID/WIDGET_TOKEN"
def push(rows):
r = requests.post(WIDGET_URL, json={"rows": rows}, timeout=5)
r.raise_for_status()
push([
{"kind": "counter", "label": "Online users", "value": 42},
{"kind": "progress", "label": "Disk", "current": 42, "max": 128},
])
Go
package main
import (
"bytes"
"encoding/json"
"net/http"
)
func PushWidget(url string, rows []map[string]any) error {
body, err := json.Marshal(map[string]any{"rows": rows})
if err != nil {
return err
}
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
PHP
$widgetUrl = "https://chattr.example.com/widgets/APP_ID/WIDGET_TOKEN";
$ch = curl_init($widgetUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"rows" => [
["kind" => "counter", "label" => "Viewers", "value" => 1247],
["kind" => "status", "label" => "Stream", "state" => "ok", "text" => "live"],
],
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);
Error reference
| Status | error |
Meaning |
|---|---|---|
400 |
rows_required / too_many_rows / row_N:<reason> |
Payload shape rejected by validator |
403 |
widget_invisible |
The widget is currently hidden; admins can unhide it in-app |
403 |
suspended |
The underlying app has been suspended |
404 |
widget_not_found |
App / token pair is unknown or the token has been rotated |
429 |
rate_limited |
30 posts/minute/IP exceeded; a Retry-After header is returned |
Permissions
Creating, rotating, pinning, and hiding widgets requires the manage_server permission. Server owners and administrators have it by default. Regular members see pinned and visible widgets but cannot change any state.
Notes
- Token rotation is immediate and one-shot — the portal shows the new URL once; copy it or re-rotate.
- Snapshots survive API restart (stored in Postgres); in-memory caches repopulate on first read.
- Rows render in the order you POST them; reordering is a property of the payload, not a server option.