# hejj app widgets: the complete spec (v1)

> For coding agents and developers. Read this file top to bottom and follow it exactly. It is
> the whole protocol; nothing else is needed. Machine-readable versions:
> [manifest schema](https://hejj.net/schemas/manifest-1.json),
> [blocks schema](https://hejj.net/schemas/blocks-1.json),
> [OpenAPI](https://hejj.net/schemas/apps-openapi-1.json). Try payloads in the
> [playground](https://hejj.net/developers/playground).
>
> `https://hejj.net` is the hosted hejj. A self-hosted hejj works the same at its own address:
> replace the base URL everywhere.

## 1. What you are building

hejj is a browser homepage with boards of tiles. Your app can put **one live widget per user
consent** on a user's boards: a balance, a build status, a to-do count, a sensor reading.

- The user approves your widget once on a hejj consent page. You get a token for that one widget.
- Your widget has 1–8 **endpoints** (views). The user picks one per tile.
- Each endpoint is **pull** (hejj calls your HTTPS URL, signed, when a tile is on screen) or
  **push** (you POST to hejj whenever data changes; open tabs update in about a second).
- You send **blocks**: JSON in a fixed vocabulary (metric, list, sparkline, …). hejj draws them.
  No HTML, no CSS, no images, no scripts.
- Optional **buttons** call your `actions_url`, signed; you answer with new blocks.
- **Privacy:** hejj never tells you who the user is. You get a random `grant_id` per user, app and
  widget. Store it against **your own** user when the flow you started finishes.

## 2. Pick your path

| Your app                                  | Connect with                                   | Data                                    |
| ----------------------------------------- | ---------------------------------------------- | --------------------------------------- |
| A web app with signed-in users            | **Redirect + PKCE** (§4)                       | pull and/or push                        |
| A server, CLI, cron job or script         | **Device code** (§5)                           | push (pull too, if you host a manifest) |
| A one-off personal script with no website | **Device code with an inline manifest** (§5.2) | push only; shown as "Unverified app"    |

There is no SDK to install: everything below is plain HTTPS and JSON, so any language works.
The calls are small; the only crypto is SHA-256 and HMAC-SHA256 (§7), which every standard
library has.

## 3. The manifest

Host it at an **https** URL. That exact URL is your `client_id`. hejj fetches it with no
redirects (answer 200 at that URL), at most 32 KB, and caches it 10 minutes.

```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",
  "device_secret_sha256": "<lowercase hex sha256 of your device secret>",
  "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 }
      },
      "context": ["timezone"],
      "endpoints": [
        {
          "id": "total",
          "title": "Balance",
          "mode": "pull",
          "url": "https://api.acme.example/hejj/total",
          "ttl": 300,
          "actions": ["refresh"]
        },
        { "id": "alerts", "title": "Alerts", "mode": "push" }
      ]
    }
  }
}
```

Rules (hejj refuses the manifest otherwise, with the JSON path of each problem):

- `hejj` is `1`. Unknown fields are refused.
- `name` ≤ 40 chars. Widget `title` ≤ 40, `description` ≤ 160.
- Ids (widget keys, endpoint `id`, action ids) match `[a-z0-9-]{1,32}`. Endpoint ids are unique
  within a widget.
- 1–8 widgets, 1–8 endpoints per widget, ≤ 3 actions per endpoint, ≤ 5 `redirect_uris`.
- **Every URL hejj fetches** (`icon`, `actions_url`, pull `url`) is `https` on the manifest's own
  host or a subdomain of it. `https://acme.example/hejj.json` may use `api.acme.example`, not
  `acme-api.example`.
- `redirect_uris` are on the manifest's exact origin, or `http://127.0.0.1:<port>/…` for native
  apps. No fragments.
- A `pull` endpoint has `url` and optional `ttl` (60–86400 s, default 300). A `push` endpoint
  has neither.
- Any endpoint with `actions` needs a top-level `actions_url`.
- `context` may list `"timezone"` and `"locale"`, only if the widget has a pull endpoint or
  actions. Each becomes an **unchecked** box on the consent page; never rely on getting them.
- `size` is in board cells: 1–24 wide, 1–40 tall, and `min ≤ default ≤ max`.
- `device_secret_sha256` enables the device flow for this hosted manifest (§5). Keep the secret
  itself on your server only.

Once a user consented, hejj keeps **the manifest they approved**. Removing things or changing
titles takes effect on its own. Anything that **widens** access (a new endpoint, a new host, a
new action, a pull/push change, new context) needs the user to consent again: send them through
§4 or §5 again; the same grant is updated (same `grant_id`, new token). Data hejj stored
for endpoints that changed (removed, pull↔push, a new pull URL) is cleared, and open tiles
refresh at once.

## 4. Connect: redirect + PKCE (web apps)

1. Make a PKCE pair: `code_verifier` = 43–128 chars of `[A-Za-z0-9-._~]` (e.g. base64url of 32
   random bytes); `code_challenge` = base64url(sha256(code_verifier)), no padding. Keep the
   verifier and a random `state` in the user's session.
2. Send the browser to:

```
https://hejj.net/connect/app?response_type=code
  &client_id=https%3A%2F%2Facme.example%2Fhejj.json
  &widget=balance
  &redirect_uri=https%3A%2F%2Facme.example%2Fhejj%2Fcallback
  &state=<random>
  &code_challenge=<challenge>
  &code_challenge_method=S256
```

An "Add to hejj" button is just this link. 3. The user signs in to hejj if needed, sees your consent page, and clicks Allow or Cancel.
hejj redirects to your `redirect_uri` with `?code=…&state=…&iss=https://hejj.net`, or
`?error=access_denied&state=…`. **Check `state`.** If the request was invalid (unknown
`client_id`, a `redirect_uri` not in your manifest, …) hejj shows an error page instead and
never redirects. 4. Within 60 seconds, from your server, exchange the code (single use):

```bash
curl -X POST https://hejj.net/api/apps/v1/token \
  -H 'content-type: application/json' \
  -d '{"grant_type":"authorization_code","code":"<code>","code_verifier":"<verifier>",
       "client_id":"https://acme.example/hejj.json","redirect_uri":"https://acme.example/hejj/callback"}'
```

Form-encoded bodies work too. Answer (store all of it, encrypted, against **your** user):

```json
{
  "access_token": "hejj_wt_…",
  "token_type": "Bearer",
  "signing_secret": "hejj_ws_…",
  "grant_id": "gr_…",
  "widget": "balance",
  "endpoints": [
    { "id": "total", "mode": "pull" },
    { "id": "alerts", "mode": "push" }
  ],
  "context": []
}
```

`context` is what the user actually granted. The `signing_secret` verifies requests hejj sends
you (§7). You never get the user's email, name or hejj id.

## 5. Connect: device code (servers, CLIs, scripts)

### 5.1 With your hosted manifest

```bash
curl -X POST https://hejj.net/api/apps/v1/device \
  -H 'content-type: application/json' \
  -d '{"client_id":"https://acme.example/hejj.json","client_secret":"<your device secret>","widget":"balance"}'
```

```json
{
  "device_code": "…",
  "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
}
```

Show the user: **"Open https://hejj.net/link and enter WDJB-MJHT"** (or the complete link). Then
poll every `interval` seconds:

```bash
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>"}'
```

| Answer                                  | Meaning                                         |
| --------------------------------------- | ----------------------------------------------- |
| 400 `{"error":"authorization_pending"}` | Keep polling.                                   |
| 400 `{"error":"slow_down"}`             | You polled faster than `interval`. Wait longer. |
| 400 `{"error":"access_denied"}`         | The user said no. Stop.                         |
| 400 `{"error":"expired_token"}`         | 10 minutes passed. Start again.                 |
| 200 token answer (§4 step 4)            | Done. A second poll gets `invalid_grant`.       |

### 5.2 With an inline manifest (no website)

Send `{"manifest": { …the manifest JSON… }, "widget": "…"}` instead of `client_id`. Inline apps
are shown as **"Unverified app"**, can't have `icon`, `redirect_uris`, `actions_url` or pull
endpoints: push only. An inline app is known by its `name`: connecting again with the same name
(even with an edited manifest) updates the same grant (same `grant_id`, new token).

## 6. Push

```bash
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"}]}'
```

- Only to your grant's `push` endpoints. The body replaces that endpoint's data.
- 200 `{"skipped":[…]}` lists block types this hejj doesn't know (left out, not refused).
- 422 `{"error":{"code":"invalid_payload","issues":[{"path":"blocks[0].currency","message":"…"}]}}`
  lists every problem. Fix them all and send again.
- ≤ 64 KB (413 above). 60 pushes/min and 1 000/day per grant; 429 has `Retry-After`.
- 401: the token was revoked (the user disconnected you, or you rotated it more than 24 h ago).
  Delete the grant on your side.
- Push when data **changes**, not on a timer. Tabs showing it update within about a second.

## 7. Pull, and verifying hejj's signature

While one of the user's tiles shows a pull endpoint, hejj calls it at most once per `ttl`:

```
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)
```

- `tz` / `locale` appear only if the user granted them. Your own query parameters in `url` are
  kept.
- No cookies, no referrer, never the user's IP (hejj's server calls you).
- Answer **200** with a blocks envelope (§8) within 5 s, ≤ 64 KB, no redirects.
- Answer **404** or **410** if you don't know this `grant` (the tile tells the user to reconnect),
  **401/403** if it was revoked on your side, **429** to slow hejj down.
- An envelope may carry `"ttl"` (≥ 60 s) to be fetched sooner than the endpoint's `ttl`.

**Verify every request** (pull and actions):

1. Parse `Hejj-Signature`: `t=<unix seconds>` and one or more `v1=<64 hex>`.
2. Reject if `|now − t| > 300`.
3. `canonical = "<t>.<METHOD>.<path and query exactly as received>.<hex sha256 of the raw body>"`
   (GET: sha256 of the empty string).
4. `expected = hex(HMAC-SHA256(key = signing_secret as UTF-8, message = canonical))`.
5. Accept if any `v1` equals `expected` (constant-time compare).

Test vector: secret `hejj_ws_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG`, `t=1760000000`,
`GET`, path `/hejj/total?grant=gr_AAAAAAAAAAAAAAAAAAAAAA&tz=Europe%2FStockholm`, empty body →
`v1=63de3a79ab7ed311fbcc01977b61a7ec0b6ebe8c0dafa483112b57f2bae91ea7`.

```js
// 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.url}.${bodyHash}`)
    .digest("hex");
  return sigs.some((s) => timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
```

```python
# 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)
```

## 8. Blocks v1

Envelope (what pull answers, push bodies and action answers are):

```json
{ "v": 1, "blocks": [ … 1 to 12 blocks … ], "href": "https://acme.example/accounts",
  "ttl": 120, "updated_at": "2026-10-12T08:00:00Z" }
```

`href` (optional) makes the tile's title a link. `updated_at` (optional) shows after your
app's name in the tile header ("Acme · 5 min ago"). Blocks render top to bottom **in priority
order**: on small tiles, the last blocks are dropped and lists/tables are cut. Put the most
important block first.

| Block       | Fields (`?` = optional)                                                                                                                                                                              |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `metric`    | `label` ≤40, `value` number or string ≤24, `unit?` `money`\|`count`\|`percent`\|`text`, `currency?` ISO 4217 (`"SEK"`), `delta?` `{value, percent?, period? ≤24, good? "up"\|"down"}`, `period?` ≤24 |
| `metrics`   | `items`: 1–8 metrics (same fields, no `type`)                                                                                                                                                        |
| `sparkline` | `values`: 2–120 numbers (oldest first), `label?` ≤40, `color?`                                                                                                                                       |
| `list`      | `items`: 1–30 of `{title ≤80, subtitle? ≤120, value? ≤24, emoji? ≤16, href?, color?, time? ISO datetime}`                                                                                            |
| `table`     | `columns`: 1–5 strings ≤24, `rows`: ≤20 rows, each exactly one cell (string ≤80 or number) per column                                                                                                |
| `progress`  | `label` ≤40, `value` ≥0, `max` >0, `value ≤ max`, `color?`                                                                                                                                           |
| `status`    | `state` `ok`\|`warn`\|`down`\|`unknown`, `label` ≤40, `detail?` ≤120                                                                                                                                 |
| `text`      | `text` ≤500 plain text (newlines kept, no markdown), `tone?` `muted`\|`normal`                                                                                                                       |
| `actions`   | `buttons`: 1–3 of `{id, label ≤24, style? primary\|secondary\|danger, confirm? ≤120, value? ≤256}`                                                                                                   |
| `empty`     | `title` ≤60, `hint?` ≤120                                                                                                                                                                            |

- Money values are whole units (`1234.5`, not cents). hejj formats numbers in the user's locale.
- `color` is a palette name: `stone red orange amber yellow lime green teal cyan blue indigo
violet purple pink rose`. Never hex.
- Links (`href`) are `http(s)` only and open in a new tab with no referrer.
- Times are ISO 8601 with a zone (`Z` or `+02:00`); hejj shows them as "5 min ago" or, in the
  future, "in 3 h". A list item with both `value` and `time` shows the time under its title.
- Control characters and bidi overrides are removed from every string.
- Unknown block types are skipped (so newer blocks don't break older hejj servers).

## 9. Buttons (actions)

A button in an `actions` block, whose `id` is in the endpoint's manifest `actions`, calls your
`actions_url` when tapped:

```
POST https://api.acme.example/hejj/actions
Hejj-Signature: t=…,v1=…        (verify as in §7, with the raw body)
content-type: application/json

{"grant_id":"gr_…","endpoint":"total","action":"refresh","button_value":"<the button's value>","tz":"…"}
```

- `button_value` is the `value` **you** put on the button (hejj reads it from the blocks you
  sent; the browser can't change it). `tz` / `locale` only if granted.
- Answer **200** with a new envelope (it replaces the endpoint's data and shows at once), **200**
  `{"toast":"Marked as paid"}` (≤140 chars), or **204**. Within 5 s.
- A button is only shown if its `id` is in that endpoint's manifest `actions` and the manifest
  has an `actions_url`; other buttons are hidden (and listed in the app's problems in hejj's
  Settings). Inline (push-only) apps have no buttons.
- A button only works while it's on screen in the latest blocks you sent; a `confirm` text asks
  the user first.

## 10. Tokens

- `GET /api/apps/v1/grant` (Bearer): `{grant_id, widget, endpoints, context}`, to check a setup.
- `POST /api/apps/v1/token/rotate` (Bearer): new `access_token` and `signing_secret`. The old
  token keeps working for 24 h; start signing-verification with the new secret at once.
- `DELETE /api/apps/v1/grant` (Bearer): uninstall. The user's tiles say you're disconnected.
- The user can disconnect you any time in hejj's Settings. Your next push gets 401.

## 11. Limits

|          |                                                               |
| -------- | ------------------------------------------------------------- |
| Manifest | 32 KB; 8 widgets; 8 endpoints/widget; 3 actions/endpoint      |
| Payload  | 64 KB; 12 blocks                                              |
| Push     | 60/min and 1 000/day per grant                                |
| Actions  | 30/min per grant                                              |
| Pull     | at most once per `ttl` (≥ 60 s) per tile context; 5 s timeout |
| Codes    | authorization code 60 s, device code 10 min                   |
| Grants   | 20 per hejj user                                              |

## 12. Common mistakes

- Hosting the manifest behind a redirect (http→https, trailing slash): hejj doesn't follow it.
- A pull `url` on a different domain than the manifest (`acme.example` vs `acmeapi.example`).
- Sending money in cents, or colors as hex.
- Verifying the signature against a parsed-and-reserialized body. Use the **raw** bytes.
- Rebuilding the path for verification instead of using it exactly as received (keep the query
  order and encoding).
- Polling the device flow faster than `interval` (you get `slow_down`).
- Assuming `tz`/`locale` arrive. They're opt-in and usually unchecked.
- Storing the grant against the wrong user: tie it to the `state` (redirect) or the session
  (device) **you** created before sending the user to hejj.
- Pushing on a timer. Push when something changes.

## 13. Checklist before you're done

- [ ] The manifest URL answers 200 with valid JSON (check it in the playground).
- [ ] The connect flow stores `grant_id`, `access_token` and `signing_secret` against your user.
- [ ] Pull and action endpoints verify `Hejj-Signature` (test with the vector in §7).
- [ ] One real payload validated in the playground.
- [ ] A 401 from hejj deletes the grant on your side.
