Site Users
A site user is a seat-free, read-only account for someone who only ever opens a published app (a webapp) — never Honeyframe's build surface. They're the right identity for the actual audience of a published app: report consumers, branch staff, partner viewers — people you want to give personal, revocable, group-scoped access to without spending a licensed seat on each of them.
A site user is always read-only, unconditionally. This isn't enforced
by which groups they happen to be in — it's checked and denied before any
group lookup runs at all, regardless of what a group would otherwise grant.
The role field on a site-user account (viewer or editor) is
bookkeeping only; it is never consulted by the permission check, so setting
role: "editor" does not grant write access. All real access comes from
group membership, exactly as described in
Groups & Permissions.
Site users belong to exactly one deployment at a time — identified by a
subscription_id, the id of the licensed product instance a published app
runs under. It's fixed per deployment, not something an end user ever
types; your Honeyframe admin or account team gives you the value.
Inviting
Only an org admin can create a site user:
/api/site-auth/invite🔒 authBody: {subscription_id: number, email: string, full_name?: string, role?: "viewer" | "editor"} (role defaults to viewer). Returns {site_user_id, email, temp_password}.
The temp password is returned once, in the response — the same "shown-once" convention as a Personal Access Token. Relay it to the person out of band; there is no invite email today, and there is currently no self-service or admin-triggered password reset for a site user either. If a credential needs to be revoked, deactivate the account (below) rather than trying to rotate its password.
Activating and managing
A site user's account is active immediately on invite — there's no separate activation step. From there, an admin manages it directly:
/api/site-auth/users/:site_user_id🔒 authBody: any of {is_active?, role?, full_name?, site_keys?}. Set is_active: false to deactivate — this takes effect immediately, on the person's very next request, not at their token's next natural expiry.
/api/site-auth/users/:site_user_id🔒 authRemoves the account entirely.
/api/site-auth/users🔒 authQuery param subscription_id. Lists a deployment's site users (admin view).
Attaching to groups
/api/site-auth/users/:site_user_id/groups/:group_id🔒 authAdds the site user to an authorization group — the same groups platform users belong to.
/api/site-auth/users/:site_user_id/groups/:group_id🔒 authRemoves the site user from the group.
A site user's access is exactly the union of the dashboard.view /
project.view grants held by every group they're in — see
Groups & Permissions for granting
those. Unlike a platform viewer, a site user gets no implicit org-wide
grant from their role: a site user in zero groups can reach nothing.
Site scoping (site_keys)
A site user can optionally be restricted to a list of site codes — whatever
your deployment models as a "site" (a branch, a hospital, a store, a
region). site_keys is a nullable list on the account:
null— unrestricted. The person is not scoped to any particular site.- A populated array (e.g.
["CWI", "JTN"]) — restricted to only those site codes.
Login flow (for application developers)
/api/site-auth/login🔒 authBody: {subscription_id: number, email: string, password: string}. Returns {access_token: string, site_user_id: number, subscription_id: number, role: string} — four fields, no more.
curl -sS -X POST "https://app.your-domain.com/api/site-auth/login" \
-H "Content-Type: application/json" \
-d '{"subscription_id":42,"email":"viewer@example.com","password":"..."}'
type SiteLoginResponse = {
access_token: string;
site_user_id: number;
subscription_id: number;
role: string;
};
async function siteLogin(
subscriptionId: number, email: string, password: string,
): Promise<SiteLoginResponse> {
const res = await fetch(`${BASE}/api/site-auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_id: subscriptionId, email, password }),
});
if (res.status === 401) throw new Error('Invalid credentials');
if (res.status === 403) throw new Error('Account deactivated');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Use the returned access_token exactly like any other bearer token —
Authorization: Bearer <token> — on every subsequent call.
Where to hold the token: prefer memory only (a JS variable / framework
context) — nothing survives a refresh, so a stolen-token window is bounded
by the tab's lifetime. sessionStorage is a reasonable, documented
exception if your app needs the session to survive a page refresh; both are
readable by any script on the page (equal XSS exposure), but sessionStorage
at least doesn't leak across tabs or browser restarts the way localStorage
does. Avoid localStorage — it has no expiry of its own and is the
highest-blast-radius option if the page is ever compromised.
Token lifetime and refresh: the token's expiry follows your
deployment's configured session lifetime — ask your operator or account
team for the exact value. There is no refresh endpoint today. On a 401,
send the person back to the login form; don't build a silent-refresh path
against an endpoint that doesn't exist.
What a logged-in site user can see — me/permissions
/api/site-auth/me/permissions🔒 authSite-user bearer token only — a platform token here is rejected. Returns the resolved menu: everything this identity is authorized to open.
This is the endpoint your app should render its navigation from — don't try
to derive access from /me (which only echoes account fields) or by
trial-and-error against the data endpoints.
{
"site_user_id": 17,
"email": "viewer@example.com",
"full_name": "Person Name",
"role": "viewer",
"subscription_id": 42,
"site_keys": null,
"groups": [
{ "id": 5, "name": "Report Viewers" }
],
"grants": [
{ "resource_type": "dashboard", "id": 12, "name": "Branch Performance", "access": "view" },
{ "resource_type": "webapp", "key": "ops-cockpit", "name": "Ops Cockpit", "access": "view" }
]
}
Field notes:
site_keysmirrors the account attribute above —nullfor unrestricted, an array for a site allowlist. See the caveat above before building anything against it.groupsis informational — the groups this identity belongs to, by id and name.grantsis the concrete, resolved list of what this identity can open. Tworesource_types:"dashboard"(keyed by numericid, matching the id used on the card-execute endpoints) and"webapp"(keyed by its publishedkey).
There is no 403 on me/permissions itself: a valid identity with zero
grants gets 200 back with grants: []. Render that as an empty state,
not an error — it's a normal condition for a freshly invited person who
hasn't been added to a group yet.
Handling 401 vs 403
These two codes mean different things and should drive different UI — don't collapse them into one generic error state.
| Status | Meaning | What to do |
|---|---|---|
401 | The token itself doesn't resolve: missing, malformed, expired — or a syntactically valid token whose account has since been deactivated or deleted. Account state is re-read on every request, so a revocation takes effect immediately, mid-session, not at the token's natural expiry. | Discard the token, redirect to login. There's no recovering in place. |
403 | The token is valid and the account is active, but this identity lacks the specific grant the call needs. | Show "no access to this report" for that resource only. Don't log the person out — the rest of the app, wherever they are granted, keeps working. |
One wrinkle: at login time, a deactivated account gets a 403
("Account deactivated"). Later, on an already-issued token, the same
deactivation surfaces as 401 instead — at that point there's no
meaningful difference between "never existed," "deleted," and
"deactivated," and none of those are a per-resource permission question.
Treat both as "this session is no longer valid" in your UI, regardless of
which code surfaces it.
The 403 body carries a fixed, parseable shape:
{
"detail": {
"error": "permission_denied",
"permission": "dashboard.view",
"target_id": "12",
"message": "Your group memberships do not grant 'dashboard.view' on 12."
}
}
async function callDataApi(req: () => Promise<Response>) {
const res = await req();
if (res.status === 401) {
clearToken();
redirectToLogin();
throw new Error('session expired');
}
if (res.status === 403) {
// scoped to this one report/card/block — do not touch the session
return { deniedThisReport: true };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
What doesn't change
Every data endpoint a published app actually calls — the config fetch, card execute, execute-batch, block execute, and filter-options preview — is identical in URL, method, request shape, and response shape whether the caller holds a Personal Access Token or a site-user token. The server resolves whichever credential kind is presented through the same dependency, and routes to whichever resolver matches. See the Webapp Data API for that full contract; this page covers only how a per-user identity is minted and what it's authorized to reach.