Docs

Widgets

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

  1. Open the developer portal → Servers → pick a server.
  2. In the Widget panel, enter a name and (optional) description, submit.
  3. 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.