Developer guide
Build a hejj widget, step by step
Show your app's live data on people's hejj boards. This guide walks you through setting it up, every endpoint, and how to call it. It's plain HTTPS and JSON, so any language works. No SDK, no signup, no review.
Using a coding agent instead? Give it the agent prompt or the one-file spec.
On this page
How it fits together
hejj is the page people see every time they open a new tab. Your app can put a live tile on it, once the user says yes.
Manifest
A small JSON file on your site that names your app and describes its widget. Its URL is your app's id (the client_id).
Widget and views
Your widget is what the user adds to a board. It has 1–8 views (called endpoints). The user picks one per tile.
Connection (grant)
The user approves your widget once. You get a token, a signing secret and a random grant_id. Never their name or email.
Blocks
What you send: JSON like “a number with a label” or “a list”. hejj draws it in every theme and size. No HTML.
Data gets to the tile in one of two ways, and you choose per view:
| How it works | Good for | |
|---|---|---|
| Push | You POST blocks to hejj whenever something changes. Open tabs update in about a second. | Alerts, builds, anything event-driven. Scripts and cron jobs. |
| Pull | hejj calls your HTTPS URL (signed) while the tile is on screen, at most once per ttl. | Data you can compute on request: balances, counts, readings. |
Your app hejj The user
──────── ──── ────────
1. host a manifest ───────────► reads it
2. "Add to hejj" / device code ► consent page ─────────────► clicks Allow
◄──────────── token + signing secret + grant_id
3a. push blocks ───────────────► stores them ──────────────► tile updates
3b. ◄──────── signed GET (pull) ─ tile on screen
answer with blocks ────────► ───────► tile updatesChoose your setup
Pick the row that matches your app. Each links to a full walkthrough.
| Your app | How the user connects | Data | Start here |
|---|---|---|---|
| A personal script, no website | Types a code at hejj.net/link | Push only | Quick start |
| A web app with signed-in users | Clicks an “Add to hejj” button | Push and/or pull | Web app guide |
| A server, CLI or cron job | Types a code at hejj.net/link | Push (pull too) | Server guide |
Self-hosting hejj?
https://hejj.net. On your own hejj, replace it with your address. Everything else is the same.Quick start: your first widget in 5 minutes
All you need is a terminal with curl and a hejj account. You'll send the manifest inline, so you don't need a website. hejj shows such apps as “Unverified app”, and they can only push.
Ask hejj for a connect code
Send your manifest and the widget you want. This one has a single push view called
main:The manifest { "hejj": 1, "name": "My first widget", "widgets": { "hello": { "title": "Hello", "endpoints": [{ "id": "main", "title": "Main", "mode": "push" }] } } }Terminal curl -X POST https://hejj.net/api/apps/v1/device \ -H 'content-type: application/json' \ -d '{"widget":"hello","manifest":{"hejj":1,"name":"My first widget", "widgets":{"hello":{"title":"Hello", "endpoints":[{"id":"main","title":"Main","mode":"push"}]}}}}'Answer { "device_code": "Zx8…", "user_code": "WDJB-MJHT", "verification_uri": "https://hejj.net/link", "verification_uri_complete": "https://hejj.net/link?code=WDJB-MJHT", "interval": 5, "expires_in": 600 }Approve it in hejj
Open
verification_uri_completein a browser where you're signed in to hejj (or open/linkand type theuser_code). Check the consent page and click Allow. The code is valid for 10 minutes.Collect your token
Exchange the
device_codefor a token. Before you click Allow this answersauthorization_pending; afterwards you get the token, once.Terminal curl -X POST https://hejj.net/api/apps/v1/token \ -H 'content-type: application/json' \ -d '{"grant_type":"urn:ietf:params:oauth:grant-type:device_code", "device_code":"<device_code from step 1>"}'Answer: keep it safe, it's shown only once { "access_token": "hejj_wt_…", "token_type": "Bearer", "signing_secret": "hejj_ws_…", "grant_id": "gr_…", "widget": "hello", "endpoints": [{ "id": "main", "mode": "push" }], "context": [] }Send your first data
Terminal curl -X POST https://hejj.net/api/apps/v1/push/main \ -H 'authorization: Bearer hejj_wt_…' \ -H 'content-type: application/json' \ -d '{"v":1,"blocks":[ {"type":"metric","label":"Coffees today","value":3,"unit":"count"}, {"type":"status","state":"ok","label":"All good"}]}'A
200means it's stored. A422lists exactly what to fix.Put it on your board
In hejj, open Add ▸ Widget. Your widget is under Connected apps. Add it, then run step 4 again with a different value and watch the tile change.
Guide: an “Add to hejj” button for your web app
Your users click a button on your site, approve on hejj, and come back connected. This is standard OAuth 2 with PKCE.
Write your manifest and host it
Put a JSON file at a stable https URL, for example
https://acme.example/hejj.json. That exact URL is yourclient_id. It must answer200directly (no redirects).https://acme.example/hejj.json { "hejj": 1, "name": "Acme Finance", "icon": "https://acme.example/icon.png", "homepage": "https://acme.example", "redirect_uris": ["https://acme.example/hejj/callback"], "actions_url": "https://api.acme.example/hejj/actions", "widgets": { "balance": { "title": "Acme balance", "description": "Your accounts at a glance", "size": { "default": { "w": 6, "h": 8 }, "min": { "w": 3, "h": 4 }, "max": { "w": 12, "h": 16 } }, "endpoints": [ { "id": "total", "title": "Balance", "mode": "pull", "url": "https://api.acme.example/hejj/total", "ttl": 300, "actions": ["refresh"] }, { "id": "alerts", "title": "Alerts", "mode": "push" } ] } } }Every URL hejj calls (
icon,actions_url, pullurl) must be https on the manifest's domain or a subdomain of it. All the rules are in the manifest reference.Check it in the playground
Paste the manifest into the playground. It lists every problem with its path, and previews your blocks in every theme.
Add the button
The button is just a link to hejj's consent page. Make a fresh PKCE pair and a random
statefor each click, and keep them in the user's session:Node import { createHash, randomBytes } from "node:crypto"; const HEJJ = "https://hejj.net"; const CLIENT_ID = "https://acme.example/hejj.json"; const REDIRECT_URI = "https://acme.example/hejj/callback"; // When the user clicks "Add to hejj": export function addToHejjUrl(session) { const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); const state = randomBytes(16).toString("base64url"); session.hejj = { verifier, state }; // remember both for the callback const url = new URL("/connect/app", HEJJ); url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, widget: "balance", redirect_uri: REDIRECT_URI, state, code_challenge: challenge, code_challenge_method: "S256", }).toString(); return url.toString(); // redirect the browser here }Handle the callback
After Allow, hejj sends the browser to your
redirect_uriwith?code=…&state=…. After Cancel you get?error=access_denied. Checkstate, then exchange the code from your server within 60 seconds:Node // GET /hejj/callback?code=…&state=… (or ?error=access_denied&state=…) export async function hejjCallback(query, session, currentUser) { const saved = session.hejj; delete session.hejj; if (!saved || query.state !== saved.state) throw new Error("state mismatch"); if (query.error) return "cancelled"; // the user clicked Cancel const res = await fetch("https://hejj.net/api/apps/v1/token", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", code: query.code, code_verifier: saved.verifier, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, }), }); if (!res.ok) throw new Error(`hejj token: ${res.status} ${await res.text()}`); const t = await res.json(); // Store against YOUR user, encrypted at rest. await db.hejjGrants.upsert({ userId: currentUser.id, grantId: t.grant_id, accessToken: t.access_token, signingSecret: t.signing_secret, }); return "connected"; }Store the connection against your own user
hejj never tells you who the user is. Thestateyou created in step 3 is how you know which of your users just connected.Send data
For push views, call the push endpoint with the access token. For pull views, serve your URL and check the signature.
Handle disconnects
If a push returns
401, the user disconnected you in hejj. Delete the stored connection and show your button again.
Guide: connect a server, CLI or cron job
For code without a browser. The user types a short code at hejj.net/link. With a hosted manifest you keep a verified name and icon, and can use pull too.
Create a device secret
Generate a random secret, keep it on your server, and put its SHA-256 hash in your manifest as
device_secret_sha256. Without it, hosted device flows are off.Terminal openssl rand -hex 32 # your secret: store it as HEJJ_DEVICE_SECRET printf %s "$HEJJ_DEVICE_SECRET" | shasum -a 256 # put this hash in the manifestStart the flow and show the code
Call
/api/apps/v1/devicewith your manifest URL and secret. Print “Open {verification_uri} and enter {user_code}”, then poll the token endpoint everyintervalseconds:Node const HEJJ = "https://hejj.net"; const start = await fetch(`${HEJJ}/api/apps/v1/device`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_id: "https://acme.example/hejj.json", client_secret: process.env.HEJJ_DEVICE_SECRET, widget: "balance", }), }).then((r) => r.json()); console.log(`Open ${start.verification_uri} and enter ${start.user_code}`); let interval = start.interval; for (;;) { await new Promise((r) => setTimeout(r, interval * 1000)); const res = await fetch(`${HEJJ}/api/apps/v1/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: start.device_code, }), }); const body = await res.json(); if (res.ok) { save(body); break; } // access_token, signing_secret, grant_id if (body.error === "authorization_pending") continue; if (body.error === "slow_down") { interval += 5; continue; } throw new Error(body.error); // access_denied or expired_token }Poll answer What to do authorization_pending Not approved yet. Keep polling. slow_down You polled too fast. Wait longer between polls. access_denied The user clicked Cancel. Stop. expired_token 10 minutes passed. Start again. 200 + token Done. Save it. Polling again gives invalid_grant. Send data
Same as every other app: push when something changes.
Sending data
Everything you send is a blocks envelope: a version and 1–12 blocks.
{
"v": 1,
"blocks": [ … 1 to 12 blocks … ],
"href": "https://acme.example/accounts", // optional: makes the tile title a link
"ttl": 120, // optional (pull): fetch again sooner
"updated_at": "2026-10-12T08:00:00Z" // optional
}Push: you send, whenever data changes
curl -X POST https://hejj.net/api/apps/v1/push/alerts \
-H 'authorization: Bearer hejj_wt_…' \
-H 'content-type: application/json' \
-d '{"v":1,"blocks":[{"type":"status","state":"warn","label":"Card expires soon"}]}'- The last part of the URL is the view's
id, and it must be a push view. - Each push replaces that view's data. Open tabs update in about a second.
- Push when data changes, not on a timer. You get 60 pushes a minute and 1 000 a day per connection.
Pull: hejj asks, while the tile is on screen
hejj calls your view's url from its server, at most once per ttl (default 5 minutes). It adds grant to the query, and tz / locale only if the user ticked those boxes:
GET https://api.acme.example/hejj/total?grant=gr_…&tz=Europe%2FStockholm
Hejj-Signature: t=1760000000,v1=<hex>
Hejj-Grant: gr_…
User-Agent: hejjBot/1.0 (+https://hejj.net/bot)import express from "express";
import { verifyHejj } from "./verify-hejj.js"; // see "Checking signatures"
const app = express();
app.get("/hejj/total", async (req, res) => {
const grant = await db.hejjGrants.findByGrantId(req.query.grant);
if (!grant) return res.status(404).end(); // tile shows "Reconnect"
if (!verifyHejj(req, "", grant.signingSecret)) return res.status(401).end();
const balance = await getBalance(grant.userId);
res.json({
v: 1,
href: "https://acme.example/accounts",
blocks: [
{ type: "metric", label: "Balance", value: balance, unit: "money", currency: "SEK" },
{ type: "actions", buttons: [{ id: "refresh", label: "Refresh" }] },
],
});
});| Answer with | When |
|---|---|
| 200 + envelope | Normally. Within 5 seconds, up to 64 KB, no redirects. |
| 404 or 410 | You don't know this grant. The tile asks the user to reconnect. |
| 401 or 403 | You revoked it on your side. |
| 429 | Slow down. hejj backs off. |
Checking hejj's signature
Every pull and button request is signed with the connection's signing_secret. Check it, so nobody else can ask for your users' data:
- Read
t=…and everyv1=…from theHejj-Signatureheader. - Reject if the time is more than 5 minutes from now.
- Build
<t>.<METHOD>.<path and query as received>.<sha256 hex of the raw body>. For GET, hash the empty string. - HMAC-SHA256 it with the signing secret, as hex.
- Accept if any v1 matches (use a constant-time compare).
// Node 18+ (no dependencies)
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
export function verifyHejj(req, rawBody, secret) {
const h = req.headers["hejj-signature"] ?? "";
const t = Number(/(?:^|,)t=(\d+)/.exec(h)?.[1]);
const sigs = [...h.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => m[1]);
if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > 300) return false;
const bodyHash = createHash("sha256").update(rawBody ?? "").digest("hex");
const expected = createHmac("sha256", secret)
.update(`${t}.${req.method}.${req.originalUrl ?? req.url}.${bodyHash}`)
.digest("hex");
return sigs.some((s) => timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}# Python 3
import hashlib, hmac, re, time
def verify_hejj(method, path_and_query, raw_body: bytes, header: str, secret: str) -> bool:
t = re.search(r"(?:^|,)t=(\d+)", header or "")
sigs = re.findall(r"v1=([0-9a-f]{64})", header or "")
if not t or not sigs or abs(time.time() - int(t.group(1))) > 300:
return False
canonical = f"{t.group(1)}.{method}.{path_and_query}.{hashlib.sha256(raw_body).hexdigest()}"
expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in sigs)Test your code with this example
hejj_ws_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG, t=1760000000, GET, path /hejj/total?grant=gr_AAAAAAAAAAAAAAAAAAAAAA&tz=Europe%2FStockholm, empty body → v1=63de3a79ab7ed311fbcc01977b61a7ec0b6ebe8c0dafa483112b57f2bae91ea7Use the raw request
Buttons
Add an actions block with up to 3 buttons. Each button's id must be listed in that view's actions in the manifest, and the manifest needs an actions_url. When the user taps one, hejj POSTs to it, signed:
{ "type": "actions", "buttons": [
{ "id": "mark-paid", "label": "Mark paid", "style": "primary",
"confirm": "Mark invoice 1042 as paid?", "value": "inv_1042" }
] }POST https://api.acme.example/hejj/actions
Hejj-Signature: t=…,v1=…
content-type: application/json
{"grant_id":"gr_…","endpoint":"total","action":"mark-paid","button_value":"inv_1042"}app.post("/hejj/actions", express.raw({ type: "*/*" }), async (req, res) => {
const msg = JSON.parse(req.body.toString("utf8"));
const grant = await db.hejjGrants.findByGrantId(msg.grant_id);
if (!grant || !verifyHejj(req, req.body, grant.signingSecret)) return res.status(401).end();
if (msg.action === "mark-paid") {
await markPaid(grant.userId, msg.button_value); // the value YOU put on the button
return res.json({ toast: "Marked as paid" });
}
if (msg.action === "refresh") return res.json(await buildEnvelope(grant)); // new blocks
res.status(204).end();
});Answer within 5 seconds with a new envelope (shown at once), {"toast":"…"} (a short message, up to 140 characters), or 204. button_value is always the value you sent: the browser can't change it. Buttons not listed in the view's manifest actions (or without an actions_url) are hidden, so push-only apps can't show buttons.
Blocks: what you can show
Ten building blocks. They render top to bottom, and on small tiles the last ones are dropped first, so put the most important block first.
| Block | Use it for |
|---|---|
| metric | One number with a label: a balance, a count, a temperature. Can show a change. |
| metrics | Up to 8 metrics side by side. |
| sparkline | A tiny line chart of 2–120 numbers, oldest first. |
| list | Up to 30 rows: title, subtitle, value, emoji, link, time. |
| table | Up to 5 columns and 20 rows of text or numbers. |
| progress | A bar: value out of max. |
| status | ok / warn / down / unknown, with a label: a build, a server, a door. |
| text | Up to 500 characters of plain text. |
| actions | Up to 3 buttons that call your server (see Buttons). |
| empty | A friendly “nothing here yet” state. |
{
"v": 1,
"blocks": [
{ "type": "metric", "label": "Balance", "value": 12480.5, "unit": "money",
"currency": "SEK", "delta": { "value": 320, "period": "this week", "good": "up" } },
{ "type": "sparkline", "values": [11900, 12010, 12160, 12480.5], "color": "green" },
{ "type": "list", "items": [
{ "title": "Rent", "value": "−9 200", "emoji": "🏠", "time": "2026-10-01T08:00:00Z" }
] }
]
}- Money is in whole units (
1234.5), not cents. hejj formats numbers for the user's language. - Colors are names:
stoneredorangeamberyellowlimegreentealcyanblueindigovioletpurplepinkrose. Never hex. - Times are ISO 8601 with a zone (
Zor+02:00). hejj shows “5 min ago”. - Links are http(s) only and open in a new tab.
See every block rendered in the block catalog, and every field with its limits in the spec (§8).
Manifest reference
hejj refuses a manifest that breaks a rule, and tells you the path of each problem.
| Field | What it is |
|---|---|
| hejj | Always 1. |
| name | Your app's name, up to 40 characters. |
| icon | Optional https image URL on your domain. |
| homepage | Optional link to your site. |
| redirect_uris | Up to 5 callback URLs on the manifest's exact origin (or http://127.0.0.1:<port> for native apps). Web apps only. |
| actions_url | Where button taps go. Required if any view has actions. |
| device_secret_sha256 | SHA-256 hex of your device secret. Turns on the device flow for this hosted manifest. |
| widgets | 1–8 widgets, keyed by id (a–z, 0–9, dashes, up to 32). |
| widgets.*.title | Up to 40 characters. description: up to 160. |
| widgets.*.size | Optional. Board cells: default, min and max (all three), 1–24 wide, 1–40 tall, min ≤ default ≤ max. |
| widgets.*.context | Optional ["timezone", "locale"]. The user can say no. |
| widgets.*.endpoints | 1–8 views: id, title, mode (pull or push). Pull adds url and ttl (60–86400 s, default 300). Up to 3 actions each. |
Changing your manifest later
grant_id and get a new token.Machine-readable: manifest JSON Schema.
API reference
Base URL: https://hejj.net. Requests and answers are JSON. The token endpoint also accepts form-encoded bodies.
The consent page. Send the user's browser here (it's your “Add to hejj” button).
Auth: none (the user signs in to hejj)
| Query | Value |
|---|---|
| response_type | code |
| client_id | Your manifest URL |
| widget | The widget id |
| redirect_uri | One of your manifest's redirect_uris |
| state | A random value you check on return |
| code_challenge | base64url(sha256(code_verifier)), no padding |
| code_challenge_method | S256 |
Returns to redirect_uri with ?code&state&iss, or ?error=access_denied&state. An invalid request shows an error page and doesn't redirect.
Start the device flow: get a short code for the user to enter at /link.
Auth: client_secret (hosted manifest) or none (inline manifest)
{ "client_id": "https://acme.example/hejj.json", "client_secret": "…", "widget": "balance" }
// or, without a website:
{ "manifest": { …inline manifest… }, "widget": "hello" }200: device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in. 400 invalid_client explains what's wrong with the manifest or secret.
Exchange an authorization code, or poll a device code, for a token.
Auth: the code itself (single use)
{ "grant_type": "authorization_code", "code": "…", "code_verifier": "…",
"client_id": "https://acme.example/hejj.json",
"redirect_uri": "https://acme.example/hejj/callback" }{ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "…" }200: access_token, token_type, signing_secret, grant_id, widget, endpoints, context. Errors are 400 {"error":"…"} (OAuth style).
Replace a push view's data with a new envelope.
Auth: Authorization: Bearer hejj_wt_…
| Status | Meaning |
|---|---|
| 200 | Stored. skipped lists block types this hejj doesn't know yet. |
| 401 | Token revoked: the user disconnected you. Delete the connection. |
| 413 | Over 64 KB. |
| 422 | Invalid: error.issues has a path and message for every problem. |
| 429 | Too many pushes. Wait for Retry-After. |
Check a token: which widget, views and context it covers.
Auth: Authorization: Bearer hejj_wt_…
200: { grant_id, widget, endpoints, context }. Handy to test a setup.
Get a new access token and signing secret.
Auth: Authorization: Bearer hejj_wt_…
The old token keeps working for 24 hours. Requests from hejj are signed with the new secret at once, so switch your verification right away.
Uninstall: end the connection from your side.
Auth: Authorization: Bearer hejj_wt_…
204. The user's tiles say your app is disconnected.
Calls hejj makes to you
| Request | When | Details |
|---|---|---|
GET your pull url?grant=… | A pull tile is on screen and its data is older than ttl | Pull |
POST your actions_url | The user taps a button | Buttons |
Both carry Hejj-Signature: check it. The full machine-readable API is the OpenAPI file.
Managing the connection
One connection (grant) per user, app and widget.
- Store
access_tokenandsigning_secretencrypted, next to your own user id and thegrant_id. - Users can disconnect you any time in hejj's Settings ▸ Connected apps. Your next push gets
401, and pull or button requests stop. - If a secret leaks, rotate it.
- hejj never sends you the user's name, email, account, IP address, or anything else on their boards. Time zone and language arrive only if they tick the box.
| Limit | |
|---|---|
| Manifest | 32 KB · 8 widgets · 8 views per widget · 3 buttons per view |
| Payload | 64 KB · 12 blocks |
| Push | 60 per minute and 1 000 per day, per connection |
| Buttons | 30 taps per minute, per connection |
| Pull | At most once per ttl (60 s or more) · 5 s timeout |
| Codes | Authorization code: 60 s · device code: 10 min |
| Connections | 20 per hejj user |
See what went wrong
| Problem | Likely cause |
|---|---|
| “Can't load the manifest” | It redirects (http→https, a trailing slash) or isn't 200 JSON. hejj doesn't follow redirects. |
| Manifest refused for a URL | A pull url, icon or actions_url on another domain. api.acme.example is fine for acme.example; acme-api.example isn't. |
| Signature never matches | You hash parsed JSON instead of the raw body, or rebuild the URL. Check your code against the test example. |
| slow_down while polling | You poll faster than interval. Wait longer between polls. |
| Push returns 422 | Read error.issues: money in cents, hex colors and long labels are common. |
| Push returns 401 | The user disconnected you, or you rotated over 24 h ago. Delete the connection. |
| Tile says “Reconnect” | Your pull URL answered 404/410 for that grant. |
| tz / locale never arrive | They're opt-in and unticked by default. Don't depend on them. |
| Wrong user got the data | Tie the connection to the state (web) or session (device) you created before sending the user to hejj. |
- The manifest URL answers 200 with valid JSON (check it in the playground).
- Connecting stores grant_id, access_token and signing_secret against your own user.
- Pull and button endpoints check Hejj-Signature (tested with the example above).
- One real payload passes in the playground.
- A 401 from hejj deletes the connection on your side.
- You push when data changes, not on a timer.