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 worksGood for
PushYou POST blocks to hejj whenever something changes. Open tabs update in about a second.Alerts, builds, anything event-driven. Scripts and cron jobs.
Pullhejj 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.
The whole picture
  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 updates

Choose your setup

Pick the row that matches your app. Each links to a full walkthrough.

Your appHow the user connectsDataStart here
A personal script, no websiteTypes a code at hejj.net/linkPush onlyQuick start
A web app with signed-in usersClicks an “Add to hejj” buttonPush and/or pullWeb app guide
A server, CLI or cron jobTypes a code at hejj.net/linkPush (pull too)Server guide

Self-hosting hejj?

Every example uses 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.

  1. 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
    }
  2. Approve it in hejj

    Open verification_uri_complete in a browser where you're signed in to hejj (or open /link and type the user_code). Check the consent page and click Allow. The code is valid for 10 minutes.

  3. Collect your token

    Exchange the device_code for a token. Before you click Allow this answers authorization_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": []
    }
  4. 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 200 means it's stored. A 422 lists exactly what to fix.

  5. 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.

That's the whole loop. Run the push from a cron job or your script whenever your data changes. To get a verified name and icon, or pull and buttons, host your manifest as in the guides below.

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.

  1. 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 your client_id. It must answer 200 directly (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, pull url) must be https on the manifest's domain or a subdomain of it. All the rules are in the manifest reference.

  2. 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.

  3. Add the button

    The button is just a link to hejj's consent page. Make a fresh PKCE pair and a random state for 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
    }
  4. Handle the callback

    After Allow, hejj sends the browser to your redirect_uri with ?code=…&state=…. After Cancel you get ?error=access_denied. Check state, 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. The state you created in step 3 is how you know which of your users just connected.
  5. Send data

    For push views, call the push endpoint with the access token. For pull views, serve your URL and check the signature.

  6. 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.

  1. 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 manifest
  2. Start the flow and show the code

    Call /api/apps/v1/device with your manifest URL and secret. Print “Open {verification_uri} and enter {user_code}”, then poll the token endpoint every interval seconds:

    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 answerWhat to do
    authorization_pendingNot approved yet. Keep polling.
    slow_downYou polled too fast. Wait longer between polls.
    access_deniedThe user clicked Cancel. Stop.
    expired_token10 minutes passed. Start again.
    200 + tokenDone. Save it. Polling again gives invalid_grant.
  3. 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.

An envelope
{
  "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

Terminal
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:

What hejj sends you
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)
Node (Express)
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 withWhen
200 + envelopeNormally. Within 5 seconds, up to 64 KB, no redirects.
404 or 410You don't know this grant. The tile asks the user to reconnect.
401 or 403You revoked it on your side.
429Slow 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:

  1. Read t=… and every v1=… from the Hejj-Signature header.
  2. Reject if the time is more than 5 minutes from now.
  3. Build <t>.<METHOD>.<path and query as received>.<sha256 hex of the raw body>. For GET, hash the empty string.
  4. HMAC-SHA256 it with the signing secret, as hex.
  5. Accept if any v1 matches (use a constant-time compare).
Node
// 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
# 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

Secret hejj_ws_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG, t=1760000000, GET, path /hejj/total?grant=gr_AAAAAAAAAAAAAAAAAAAAAA&tz=Europe%2FStockholm, empty body → v1=63de3a79ab7ed311fbcc01977b61a7ec0b6ebe8c0dafa483112b57f2bae91ea7

Use the raw request

Hash the body bytes exactly as received, and use the path and query exactly as sent (same order and encoding). Re-serialized JSON or a rebuilt URL won't match.

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:

The button in your blocks
{ "type": "actions", "buttons": [
  { "id": "mark-paid", "label": "Mark paid", "style": "primary",
    "confirm": "Mark invoice 1042 as paid?", "value": "inv_1042" }
] }
What hejj sends your actions_url
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"}
Node (Express)
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.

BlockUse it for
metricOne number with a label: a balance, a count, a temperature. Can show a change.
metricsUp to 8 metrics side by side.
sparklineA tiny line chart of 2–120 numbers, oldest first.
listUp to 30 rows: title, subtitle, value, emoji, link, time.
tableUp to 5 columns and 20 rows of text or numbers.
progressA bar: value out of max.
statusok / warn / down / unknown, with a label: a build, a server, a door.
textUp to 500 characters of plain text.
actionsUp to 3 buttons that call your server (see Buttons).
emptyA friendly “nothing here yet” state.
A typical envelope
{
  "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: stone red orange amber yellow lime green teal cyan blue indigo violet purple pink rose. Never hex.
  • Times are ISO 8601 with a zone (Z or +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.

FieldWhat it is
hejjAlways 1.
nameYour app's name, up to 40 characters.
iconOptional https image URL on your domain.
homepageOptional link to your site.
redirect_urisUp to 5 callback URLs on the manifest's exact origin (or http://127.0.0.1:<port> for native apps). Web apps only.
actions_urlWhere button taps go. Required if any view has actions.
device_secret_sha256SHA-256 hex of your device secret. Turns on the device flow for this hosted manifest.
widgets1–8 widgets, keyed by id (a–z, 0–9, dashes, up to 32).
widgets.*.titleUp to 40 characters. description: up to 160.
widgets.*.sizeOptional. Board cells: default, min and max (all three), 1–24 wide, 1–40 tall, min ≤ default ≤ max.
widgets.*.contextOptional ["timezone", "locale"]. The user can say no.
widgets.*.endpoints1–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

hejj keeps the version each user approved. Renames and removals apply on their own. Anything that asks for more (a new view, host, button, or context) needs the user to connect again; they keep the same 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.

GET/connect/app

The consent page. Send the user's browser here (it's your “Add to hejj” button).

Auth: none (the user signs in to hejj)

QueryValue
response_typecode
client_idYour manifest URL
widgetThe widget id
redirect_uriOne of your manifest's redirect_uris
stateA random value you check on return
code_challengebase64url(sha256(code_verifier)), no padding
code_challenge_methodS256

Returns to redirect_uri with ?code&state&iss, or ?error=access_denied&state. An invalid request shows an error page and doesn't redirect.

POST/api/apps/v1/device

Start the device flow: get a short code for the user to enter at /link.

Auth: client_secret (hosted manifest) or none (inline manifest)

Body
{ "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.

POST/api/apps/v1/token

Exchange an authorization code, or poll a device code, for a token.

Auth: the code itself (single use)

Body: after the redirect
{ "grant_type": "authorization_code", "code": "…", "code_verifier": "…",
  "client_id": "https://acme.example/hejj.json",
  "redirect_uri": "https://acme.example/hejj/callback" }
Body: device flow
{ "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).

POST/api/apps/v1/push/{endpoint}

Replace a push view's data with a new envelope.

Auth: Authorization: Bearer hejj_wt_…

StatusMeaning
200Stored. skipped lists block types this hejj doesn't know yet.
401Token revoked: the user disconnected you. Delete the connection.
413Over 64 KB.
422Invalid: error.issues has a path and message for every problem.
429Too many pushes. Wait for Retry-After.
GET/api/apps/v1/grant

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.

POST/api/apps/v1/token/rotate

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.

DELETE/api/apps/v1/grant

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

RequestWhenDetails
GET your pull url?grant=…A pull tile is on screen and its data is older than ttlPull
POST your actions_urlThe user taps a buttonButtons

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_token and signing_secret encrypted, next to your own user id and the grant_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
Manifest32 KB · 8 widgets · 8 views per widget · 3 buttons per view
Payload64 KB · 12 blocks
Push60 per minute and 1 000 per day, per connection
Buttons30 taps per minute, per connection
PullAt most once per ttl (60 s or more) · 5 s timeout
CodesAuthorization code: 60 s · device code: 10 min
Connections20 per hejj user

See what went wrong

In hejj, Settings ▸ Connected apps shows each app's recent problems: refused pushes, failed pulls and failed buttons, with the reason.
ProblemLikely 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 URLA pull url, icon or actions_url on another domain. api.acme.example is fine for acme.example; acme-api.example isn't.
Signature never matchesYou hash parsed JSON instead of the raw body, or rebuild the URL. Check your code against the test example.
slow_down while pollingYou poll faster than interval. Wait longer between polls.
Push returns 422Read error.issues: money in cents, hex colors and long labels are common.
Push returns 401The 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 arriveThey're opt-in and unticked by default. Don't depend on them.
Wrong user got the dataTie 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.