# Zetapad developer documentation Index: https://zetapad.com/llms.txt # Introduction Build a hosted app, launch it as a Zetapad room, and connect to members with their permission. Source: https://zetapad.com/developers/docs ## What you can build Host a web app on your own infrastructure and register it as a room in Zetapad. Rooms can be learning tools, games, or other interactive experiences. Any signed-in member can register and self-publish an app. One app registration creates one room entry. Your app owns its interface, hosting, activity state, multiplayer behavior, and stored data. Zetapad supplies the room launcher, member consent, approved profile fields, and [scoped call access](https://zetapad.com/developers/docs/calls) through REST and hosted tiles. ## Start building 1. Follow the [quickstart](https://zetapad.com/developers/docs/quickstart) to register and preview your app. 2. Configure [iframe embedding](https://zetapad.com/developers/docs/embedding) on your hosted launch page. 3. Implement [authentication](https://zetapad.com/developers/docs/authentication) with the [browser example](https://zetapad.com/developers/docs/examples/browser) and [server example](https://zetapad.com/developers/docs/examples/server). 4. Follow [restrict app access](https://zetapad.com/developers/docs/access-control) if your app must work only through Zetapad. 5. Add [campus calls](https://zetapad.com/developers/docs/calls) with the [hosted call example](https://zetapad.com/developers/docs/examples/calls) when your app needs call tiles, controls, or shared activities. Call access is available by default with registered scopes, verified login, and native member approval. 6. [Publish your app](https://zetapad.com/developers/docs/publishing) to make it available in Rooms. ## How the integration works The room runs your launch URL in an iframe. Your browser code sends version 2 `postMessage` requests to the Zetapad parent. The `ready` method identifies the current launch. The `authenticate` method asks for consent and returns only a short-lived authorization code, its expiry, and the echoed state challenge. It does not return member IDs or profiles. Every app requires a backend or serverless function to complete login, even with no profile scopes. Your backend validates a session-bound state challenge, exchanges the code using its client ID and secret, and reads the verified member from the REST API. Keep access tokens and client secrets on your server. There is no browser profile method; the app starts after backend login succeeds. REST userinfo returns the same server-generated primary `userId` for a given Zetapad account across approved apps, even with `scopes: []`. Use the server-verified ID to recognize returning members and build persistent experiences across apps. Your backend may return display data to its UI after login, but protected actions must resolve identity from the server session. See [why the backend is required](https://zetapad.com/developers/docs/authentication#why-the-backend-is-required) and [persist experiences across apps](https://zetapad.com/developers/docs/authentication#persist-experiences-across-apps). There is no distributed JavaScript SDK. The examples are raw protocol code to adapt to your framework. There are no refresh tokens or browser-only token exchanges. ## Production origins | Purpose | Origin | | --- | --- | | Trusted iframe parent | `https://zetapad.com` | | Trusted iframe parent | `https://www.zetapad.com` | | REST API base URL | `https://zetapad.com` | Register the final origin of your app's launch URL. Redirecting the iframe to a different origin prevents it from using the bridge. ## Use these docs with an LLM Every topic has a public Markdown version linked beside its title. Use [the documentation index](https://zetapad.com/llms.txt) to choose relevant pages or [the complete documentation](https://zetapad.com/llms-full.txt) for a single context file. For exact contracts, use [the REST OpenAPI document](https://zetapad.com/developers/docs/openapi.json) and [the iframe JSON Schema](https://zetapad.com/developers/docs/protocol.schema.json). Examples are included from the same tested source used by the website. --- # Quickstart Register your app, complete the iframe handshake, and authenticate your first member. Source: https://zetapad.com/developers/docs/quickstart ## Prerequisites You need a completed Zetapad profile with Developer access enabled by an administrator, a hosted web app, and a backend that can keep a client secret and server-side visitor sessions. Only enabled profiles see Developers and can manage apps or use previews. Every app requires backend login, including apps with zero scopes; a small serverless function is sufficient. Your app must use an origin separate from Zetapad. Public apps require HTTPS; local previews have the exceptions described in [embedding](https://zetapad.com/developers/docs/embedding). ## Register your app 1. Open the [developer dashboard](https://zetapad.com/campus/developers). 2. Create an app with a name, description, launch URL, and required permissions. 3. Save the one-time client secret on your backend as `ZETAPAD_CLIENT_SECRET`. 4. Save the app's client ID as `ZETAPAD_CLIENT_ID`. The client ID remains visible on the app page. Request only the profile fields your app uses. You may register `scopes: []`: backend userinfo still returns the stable shared server `userId` after approval and credential exchange. Never include the secret in a URL, browser bundle, analytics, or logs. ## Prepare the launch page Allow Zetapad to embed your app by sending this HTTP response header on every app document: ```http Content-Security-Policy: frame-ancestors https://zetapad.com https://www.zetapad.com; ``` Merge this directive into your existing CSP. Remove conflicting `X-Frame-Options: DENY` or `SAMEORIGIN` headers. See [embedding](https://zetapad.com/developers/docs/embedding) for sandbox limitations. If the app must run only in Zetapad, initially render only a public loading shell and apply the [access-control guide](https://zetapad.com/developers/docs/access-control). Protected data and app controls become available only after the server verifies authentication. ## Add the integration Use the complete [browser example](https://zetapad.com/developers/docs/examples/browser) and [server example](https://zetapad.com/developers/docs/examples/server). Supply your framework's routing, session middleware, `startYourApp`, and `showLoginError`. The required sequence is: 1. Install the message listener and check `event.source === window.parent` and the exact trusted `event.origin`. 2. Call `ready` with empty params and retain its `launchId`. 3. Fetch a random challenge from your backend's `GET /zetapad/state` endpoint. Store it in the same server-side session used for login. 4. Send version 2 `authenticate` with `{ launchId, state }` and wait for member consent. The success reply contains only the one-use `code`, `expiresIn`, and echoed `state`. 5. Submit the returned code and echoed state to your backend's `POST /zetapad/login`. 6. Validate and consume the session challenge, exchange the code at `POST https://zetapad.com/v1/apps/token`, and store the access token on your backend. 7. Read `GET https://zetapad.com/v1/apps/userinfo` using the bearer token, establish the server session, and then start the app. Use the verified `userId` to store persistent experiences across apps; later protected actions resolve it from that session. The `/zetapad/state` and `/zetapad/login` endpoints belong to your app. Zetapad supplies `/v1/apps/token` and `/v1/apps/userinfo`. The iframe bridge exposes no member IDs or profiles. Your backend may return approved display fields after login. Do not create a session from browser-supplied identity or start the app when backend login fails. See [why verification belongs on the backend](https://zetapad.com/developers/docs/authentication#why-the-backend-is-required). ## Add campus calls To use a member's existing call in your app, register `calls:read` and add `calls:control` for invitations or call actions. App calls are available by default; permissions and native per-call approval still apply. After the normal backend login, follow the [calls guide](https://zetapad.com/developers/docs/calls) and [hosted call example](https://zetapad.com/developers/docs/examples/calls) to attach, select speaker tiles or audio-only, and hand presentation back to Zetapad. Your backend uses REST and your browser mounts hosted iframes. ## Verify in Preview Use Preview on your unpublished app's developer page. Only its developer owner can launch an unpublished preview. Approve the registered permissions, confirm that login finishes, and check that fields outside those permissions are omitted. If you require exclusive Zetapad access, open the app URL in a separate browser tab and confirm that it returns a blocked page. Also verify that direct requests to protected API endpoints fail without a valid app session. ## Publish Publish the HTTPS launch URL to list the app in [Rooms](https://zetapad.com/campus/rooms). You can edit its name, description, URL, and scopes while it stays published. Saves invalidate issued app access; members reopen and authenticate, with fresh consent for scope or origin changes. See [publishing and app lifecycle](https://zetapad.com/developers/docs/publishing). --- # Iframe embedding Configure your launch URL, embedding headers, and local development environment. Source: https://zetapad.com/developers/docs/embedding ## Allow the Zetapad parent Send an HTTP CSP header that allows both production parent origins: ```http Content-Security-Policy: frame-ancestors https://zetapad.com https://www.zetapad.com; ``` Merge `frame-ancestors` into any existing CSP rather than replacing unrelated directives. Apply it to every app HTML response, including error pages and nested routes. The directive must be sent in an HTTP header; a meta tag cannot enforce `frame-ancestors`. Do not send `X-Frame-Options: DENY` or `SAMEORIGIN` on those responses. Both prevent the intended cross-origin embedding. If your host sets either header automatically, override it for your app documents. `frame-ancestors` controls which pages may embed your app. It does not block top-level navigation or authenticate callers. Follow [restrict app access](https://zetapad.com/developers/docs/access-control) when the direct URL must be unusable. ## Iframe capabilities The iframe permits scripts, forms, and its own origin. Zetapad does not delegate top navigation, popups, camera, or microphone. Register the final launch origin. An iframe redirected to another origin cannot use the bridge. Keep navigation within the registered origin, send `ready` when the app loads, and reopen the Zetapad room after iframe navigation. Rooms and previews fill the browser viewport. Your app controls its interface. A small transparent Zetapad logo bubble sits above it; members can drag it to an edge and open controls to reload or exit. Consent dialogs appear above the room when needed. ## Validate messages Install your message listener before sending `ready`. Accept only replies from `window.parent` with either `https://zetapad.com` or `https://www.zetapad.com` as their exact origin. After a valid readiness reply, pin all further messages to that responding origin. Use an exact origin as the `postMessage` target. The [browser example](https://zetapad.com/developers/docs/examples/browser) supports both production origins without using a wildcard target. ## Hosted call tiles Apps using [campus calls](https://zetapad.com/developers/docs/calls) embed Zetapad-hosted video tiles inside their registered app iframe. Merge a `frame-src` directive into your app's CSP: ```http Content-Security-Policy: frame-ancestors https://zetapad.com https://www.zetapad.com; frame-src https://zetapad.com https://www.zetapad.com; ``` Resolve each relative tile URL against the exact origin pinned by `ready`, including any local port. Tile and campus origins must match even though production REST requests use `https://zetapad.com`. Mount tile iframes directly inside the registered app document. Ordinary child iframes work; an extra sandbox must preserve scripts and `allow-same-origin` so the owning campus can attach its existing video. Keep the tile on its campus origin, use each returned URL once, and request fresh URLs after reload. Tile documents permit only the registered app and their own campus origin as ancestors. They capture no devices and render muted video; Zetapad retains incoming audio and native camera/microphone controls. See the [hosted call example](https://zetapad.com/developers/docs/examples/calls) for mounting, commit, heartbeat, and release. ## Local development Unpublished previews may use HTTP on `localhost`, `127.0.0.1`, or `[::1]`, on an origin separate from Zetapad. Publishing always requires HTTPS. When running Zetapad locally at `http://localhost:3000`, configure the app document's header as: ```http Content-Security-Policy: frame-ancestors http://localhost:3000; ``` Replace the browser example's trusted origins with `["http://localhost:3000"]` and the server example's API origin with `http://localhost:3000`. These changes are for local integration testing. Production uses the fixed Zetapad origins above. For local hosted tiles, also allow `http://localhost:3000` in `frame-src` and in the call example's tile-origin allowlist. Set its REST helper base URL to `http://localhost:3000/v1/apps/calls`. Keep the registered app on a different origin or port. An HTTPS tunnel can help if your browser restricts an HTTP local iframe inside an HTTPS parent. ## App session cookies Zetapad authentication itself does not require third-party cookies. Your own app's session storage still needs to work in an embedded browser context. Use HttpOnly, Secure cookies on HTTPS. Cross-site iframe cookies generally need `SameSite=None`; browser privacy settings may still restrict them. Configure a supported session strategy for your app and test it in the intended browsers. The server must resolve the visitor session itself, never accept a session identity from the request body. --- # Authentication and permissions Understand member consent, server-side login, profile permissions, and credential expiry. Source: https://zetapad.com/developers/docs/authentication ## Authentication flow Every app must implement backend authentication, including apps with no profile scopes. A serverless function can serve as the backend. The version 2 iframe bridge carries readiness and authorization codes; member IDs and profile fields are available only through the authenticated REST API. 1. Run the browser code in the registered iframe and complete `ready`. 2. Have your backend generate a random state challenge bound to the visitor's server-side session. 3. Call iframe `authenticate` with the current `launchId` and that state. Zetapad requests member consent. 4. Receive only `{ code, expiresIn, state }` from the bridge and forward the code and state to your backend. 5. Validate and consume state in the same visitor session, then exchange the one-use code with your client ID and secret at `POST /v1/apps/token`. 6. Call `GET /v1/apps/userinfo` with the returned bearer token to obtain the verified member. 7. Establish app access in the server session and initialize the browser UI only after this succeeds. Use the complete [browser example](https://zetapad.com/developers/docs/examples/browser) and [server example](https://zetapad.com/developers/docs/examples/server). The iframe receives the code; your backend retrieves `userId`, scopes, and approved profile fields from userinfo. ## Why the backend is required An app that trusts a browser request such as `{ "userId": "zp_member_123", "progress": 80 }` lets the caller choose whose progress to overwrite. A member can change request bodies and browser code. A stable ID identifies a record; it does not prove who is making a request. Your backend verifies the authorization code with Zetapad using credentials that stay on the server. Userinfo supplies the member identity for that session. Protected reads and writes use this verified `userId` rather than an ID in the request body or URL. Check current access before the action, even with `scopes: []`. The iframe can render a public loading shell without credentials, but readiness or consent alone does not complete app login. Every app must finish the backend flow before enabling its experience. Zetapad enforces credentials for its member data; your hosted app enforces its own sessions, protected routes, and [direct URL restrictions](https://zetapad.com/developers/docs/access-control). ## Identity in the browser Your backend may return verified `userId` and approved display fields after login if your UI needs them. It may instead return only the fields the UI uses. The ID is not a secret, and sending it to the UI does not turn it into a credential. Client secrets and bearer tokens must remain on the backend. On later requests, resolve the member through the server session. Do not authorize access using an ID the browser sends back, even if your backend originally returned it. ## Member consent `authenticate` asks the member to approve every registered scope or cancel. Returning members reuse their saved approval. Changing the registered origin or required scopes asks for approval again. Apps with no registered scopes still ask for approval to identify the member. The consent dialog explains that the shared Zetapad user ID lets developers recognize the same account across apps. When the member cancels, the bridge returns `CONSENT_DENIED` and Zetapad closes the room. Your app should stop initialization and avoid automatically reopening consent. ## Scope changes and reauthentication Developers can edit published registrations. Every save invalidates existing launches, codes, and access tokens. Adding or removing registered scopes, or changing the launch origin, also invalidates remembered consent. Changes to the name, description, or URL path/query on the same origin preserve consent. Your backend must verify current userinfo and its `scopes` before enabling features that require those permissions, even when the visitor has an existing app session. Approval of an earlier scope list does not authorize newly requested scopes; this system grants the complete current list rather than retaining partial access. After `ACCESS_REVOKED` caused by an update, stop protected actions and polling and have the member reopen the room for a fresh launch. Run `ready`, generate fresh session-bound state, call `authenticate`, and exchange the new code on the backend. Zetapad presents the current permissions when new approval is required. Resume only after backend verification succeeds; stop on `CONSENT_DENIED`. Calling `authenticate` on the old launch cannot renew it after a registration save. For app calls, clear stale grant, presentation, and tile bindings. The native call continues while app presentation returns to Zetapad. After renewed scope/origin consent, attach again and obtain native call approval before preparing a fresh presentation. Routine token expiry with unchanged configuration can use `authenticate` on the same valid launch and preserve the live call presentation. ## Permissions and profile fields | Scope | Fields added | Meaning | | --- | --- | --- | | `profile:read` | `username`, `name`, `countryCode` | Member's current username, name, and country | | `birthdate:read` | `birthdate` | Full birthdate in `YYYY-MM-DD` format | | `calls:read` | Silent owning-tab call availability; public roster and hosted tiles | Status needs normal app consent; roster and tiles also require native approval for each app and participation | | `calls:control` | Call invitation, stage, playback, and activity requests | Requires `calls:read`; native and server call rules apply | Successful REST userinfo responses always include `userId`, Zetapad's server-generated primary member ID shared across apps, and `scopes`. No profile or birthdate scope is required for this ID. It requires member approval and a token obtained with the app's client ID and secret. An app with `scopes: []` receives this ID after approval and can use it for saved experiences without collecting profile fields. `scopes` is the granted scope list. Unapproved fields are omitted. `name`, `countryCode`, and `birthdate` can be `null` until the member completes their profile. Do not treat omitted and null fields as the same condition. `username` is a unique public handle of 3–30 lowercase letters, numbers, or underscores. Every member has one, including before profile completion. Members can change it anytime in their profile; the previous handle becomes available to another member. With `profile:read`, userinfo returns the current username on every read. Store app records under the permanent `userId`; use `username` for display and recognition. Existing profile approvals must be renewed once to include username sharing. ```json { "userId": "zp_member_...", "scopes": ["profile:read"], "username": "member_01", "name": "Member", "countryCode": "IN" } ``` Client secrets, access tokens, Firebase IDs, Firebase tokens, and Zetapad account credentials are never provided as profile fields. Your app owns the handling and retention of data it receives. ## Persist experiences across apps `userId` is Zetapad's server-generated primary member ID. Treat it as an opaque string. It is not a Firebase UID, email address, or value generated separately by each app. Firebase identifiers remain private to Zetapad's sign-in mapping. Store saved progress, preferences, or other member records under the `userId` returned by your backend's verified userinfo call. The same account receives the same `userId` across registrations and across later sessions, even after app disconnection and reconnection, secret rotation, or username and profile changes. Different accounts have different public IDs. Profile completion is not required. Deleting and recreating an app registration also preserves the member's `userId`. Authentication does not share an app's saved data automatically. Apps must implement their own storage and any intended sharing. Never accept a browser-supplied `userId` as proof of access; verify the session and userinfo before reading or writing member data. ## Credential lifetime | Credential | Lifetime | Usage | | --- | --- | --- | | Launch ID | 1 hour | Bound to the current room; reopen the room when stale or expired | | Authorization code | 60 seconds | Exchange once, using its registered app's credentials | | Access token | 15 minutes | Read approved userinfo from the backend | Obtain a fresh code through `authenticate` to renew access. There are no refresh tokens or browser-only token exchanges. The server example's visitor state challenge expires after five minutes and is consumed on a login attempt; this challenge belongs to your app. ## Revocation and app sessions Registration saves, member disconnection, unpublishing, deleting the app, and secret rotation end issued Zetapad access immediately. Republishing does not restore old codes or tokens. Rotation and unpublishing preserve remembered consent; scope or origin changes require it again. Deletion removes the registration and connections. Check userinfo whenever a protected action relies on current Zetapad access, including after your app has created its own session. End that session's access when the token expires or userinfo fails. Disconnection prevents future Zetapad data reads; it cannot erase data your app already received. Never put secrets or bearer tokens in browser code, iframe URLs, analytics, or logs. For route-level enforcement, follow [restrict app access](https://zetapad.com/developers/docs/access-control). --- # Restrict app access Block direct browser launches and protect your app's data and actions with verified Zetapad access. Source: https://zetapad.com/developers/docs/access-control ## Required behavior If your app is available only through Zetapad, opening its URL directly in a browser must display a blocked page rather than a usable app. A permitted iframe initially receives only a public bootstrap shell. The app becomes usable after a trusted parent handshake and server-verified Zetapad authentication. Your hosted app enforces this policy. Registering a URL in Zetapad does not change your hosting provider's route access rules. Every app must complete backend authentication before becoming usable, including with no scopes. Protocol version 2 exposes only readiness and a one-use code in the iframe. Member IDs and profile fields require the app's authenticated REST calls; there is no browser-only identity flow. Zetapad enforces this boundary for its data, and the app host enforces its own route and session policy. ## Restrict app document requests Apply a document-route guard on your server and send the [embedding CSP](https://zetapad.com/developers/docs/embedding) on every app HTML response: ```http Content-Security-Policy: frame-ancestors https://zetapad.com https://www.zetapad.com; Cache-Control: no-store ``` For strict browser launch control, accept the document only when `Sec-Fetch-Dest` is exactly `iframe`. A normal direct navigation sends `document` and receives `403`. Reject missing metadata in strict mode as well. `Sec-Fetch-Site` and `Referer` do not reliably identify the exact parent and must not be used as authentication. ```javascript // Express-style middleware. Apply only to HTML document routes. export function requireZetapadFrame(request, response, next) { // Strict mode: missing Fetch Metadata is rejected too. if (request.headers["sec-fetch-dest"] !== "iframe") { return response.status(403).set("Cache-Control", "no-store") .send("Open this app from Zetapad Rooms."); } next(); } // Register every launch and app document route with the guard: // app.get("/", requireZetapadFrame, serveBootstrap); // app.get("/play", requireZetapadFrame, serveBootstrap); // serveBootstrap serves only a public shell, with the frame-ancestors CSP. // Assets and /zetapad/state and /zetapad/login use their own route handlers. // Every protected API handler must verify its server-side Zetapad session. ``` Apply the guard to every app document route, including nested pages. Set the CSP in shared response middleware so it also reaches blocked and error pages. Asset requests, state/login endpoints, and app APIs are not document navigations and need their own handlers. Fetch Metadata headers are browser launch controls. A non-browser client can spoof them, and unsupported browsers may omit them. Consequently, the returned shell must contain no private data or usable protected functionality. The backend session checks below are still required. ## Block top-level startup in the browser Before the handshake or app initialization, check `window.self === window.top`. If true, show "Open this app from Zetapad Rooms" and stop. Leave protected UI hidden while loading; reveal it only after the backend login succeeds. The [browser example](https://zetapad.com/developers/docs/examples/browser) rejects top-level startup before sending messages or making login requests. In an iframe, it verifies `event.source === window.parent` and the exact Zetapad parent origin, pins that origin after `ready`, and waits for verified backend login before `startYourApp`. This check controls browser behavior. It cannot authenticate an API request or prevent someone from editing JavaScript on their own computer. ## Require a verified backend session 1. Generate a random, expiring state challenge in the visitor's server-side session. Do not reuse a shared state value. 2. Obtain a code through `authenticate` inside the trusted Zetapad iframe. 3. Validate and consume the echoed state in that same server-side session. 4. Exchange the code with your client ID and secret on the backend. 5. Fetch userinfo from Zetapad and establish app access for the verified `userId`. 6. On every protected API route, resolve the server session and check current Zetapad access before reading data or performing an action. Use the [server example](https://zetapad.com/developers/docs/examples/server). `readZetapadMember(session)` rejects a missing or expired session token and checks userinfo for current access. Call it before each protected read or action. Your routing layer should return an authentication error if it fails and must not continue the protected handler. Keep the token on the server. A query parameter such as `?from=zetapad`, a browser-supplied profile or `userId`, a `launchId` by itself, an `Origin` header, or a successful `ready` reply is not proof of backend authentication. A successful iframe `authenticate` reply provides a code to exchange; it does not establish your app's session. Even if your backend returns the ID for display, later requests must resolve identity from the server session rather than trust an ID sent back by the browser. Use secure session cookies, CSRF protection for state-changing requests, and the exact session binding described in the examples. If a browser blocks your embedded session mechanism, show an error and stop initialization rather than granting anonymous access. ## What this guarantees In supported browsers, document guards reject direct top-level navigation even when the visitor already has an app session. The CSP prevents other sites from embedding the app, and browser startup requires the trusted Zetapad parent. Protected APIs require a valid server-side Zetapad session and current token access. The current protocol does not provide a server-verifiable signal proving that an iframe is still open for each subsequent API request. A valid app session can make API requests until your session policy, token expiry, or userinfo revocation check ends access. If you require per-request proof of a currently mounted iframe, that requires a separate protocol feature; do not claim an iframe check or CSP provides it. ## Acceptance checks - Directly opening the launch URL returns `403`; a client-side fallback also prevents startup. - Directly opening any nested app document returns the same blocked result, including with an existing app session. - An unrelated site cannot embed the app because its origin is absent from `frame-ancestors`. - A permitted iframe shell has no private data before authentication. - The app initializes after a valid Zetapad handshake, consent, state validation, code exchange, and userinfo read. - An unauthenticated call to every protected API route fails. - Iframe authentication replies contain exactly `code`, `expiresIn`, and `state`. - A spoofed profile, guessed launch ID, mismatched state, or reused code cannot establish app access. - Expired tokens, member disconnection, unpublishing, deletion, and secret rotation prevent further protected reads and actions. --- # Publishing and app lifecycle Preview, publish, update, rotate credentials, and delete your registered app. Source: https://zetapad.com/developers/docs/publishing ## Register and preview Create an app in the [developer dashboard](https://zetapad.com/campus/developers) with a name, description, launch URL, and required permissions. Save the one-time client secret on your server. The client ID stays visible on the app page. One registration is one room entry. Your app handles hosting, activity state, invitations, and multiplayer behavior. Only members with Developer access enabled by an administrator can build and self-publish. Use Preview while your app is unpublished. Only its developer owner can launch an unpublished preview. Test version 2 embedding, backend code exchange and userinfo even with zero scopes, and any [exclusive access rules](https://zetapad.com/developers/docs/access-control) before publishing. Readiness and a code reply alone do not complete app login. ## Publish an HTTPS URL Publish your app to list it in [Rooms](https://zetapad.com/campus/rooms). The launch URL must use HTTPS and a separate origin from Zetapad. Local HTTP exceptions apply only to unpublished previews. Members launch the app in a full-viewport iframe and approve its registered permissions. Your app must continue to enforce its own hosting and data access policies. ## Update an app Edit the name, description, launch URL, and required scopes from the app's developer page, including while published. Saving keeps the same client ID and secret, preserves publication, and updates the room listing. Published updates must keep an HTTPS launch URL on an origin separate from Zetapad. To use a local HTTP URL, unpublish first. Each save invalidates existing launches, authorization codes, and access tokens. Members must reopen the room and complete backend authentication again. Name, description, and URL path/query changes on the same origin preserve remembered consent. Changing the launch origin or adding or removing scopes requires approval of the current registered permissions; old approval grants no new access. Check the current `scopes` returned by authenticated userinfo before enabling protected features. An existing app session cannot replace current Zetapad permission. On `ACCESS_REVOKED`, stop protected actions and polling, clear stale call bindings, and have the member reopen the room. Complete `ready`, obtain a fresh session-bound state, call `authenticate`, and exchange the new code on your backend. Handle `CONSENT_DENIED` by stopping initialization. See [scope changes and reauthentication](https://zetapad.com/developers/docs/authentication#scope-changes-and-reauthentication). App call presentation returns to Zetapad after access loss while the native call and device choices continue. After a scope or origin change, attach using the fresh login, obtain native call approval again, and prepare new tiles. Ordinary token renewal within a valid unchanged launch preserves the call presentation. Unpublishing also immediately ends issued access. Republishing does not revive old credentials. Rotation and unpublishing preserve remembered consent when the configuration still permits it. ## Rotate a secret Rotate the secret from your app's developer page, then update `ZETAPAD_CLIENT_SECRET` on your backend. Keep the new value out of browser code and logs. Rotation invalidates issued access immediately, so reconnect members through the iframe authentication flow. The client ID remains the same. Remembered consent is preserved. ## Delete an app Unpublish first, then select Delete app on its developer page and confirm. Deletion permanently removes the registration and member connections and invalidates client credentials, launches, codes, and tokens immediately. Creating another app produces a new client ID and secret and requires fresh member consent. Your app remains responsible for any data or sessions it previously stored. --- # Campus calls in apps Use REST and hosted video tiles to share an existing campus call and invite members into app activities. Source: https://zetapad.com/developers/docs/calls ## Access and login App calls are available by default. Register `calls:read` for silent call availability, the approved public call roster, and hosted tiles. Add `calls:control` for invitations, call/stage actions, playback mute, and activity offers. Control requires read. Existing registrations gain no permissions automatically; adding these scopes requires fresh member consent. Complete the normal version 2 [backend login](https://zetapad.com/developers/docs/authentication). Its `ready` and `authenticate` replies are unchanged. The server-held bearer token proves login for that launch. Use `https://zetapad.com/v1/apps/calls` as the production REST base URL and `Authorization: Bearer ` on every API request. There is no calls SDK or calls `postMessage` API to implement. The launch must be mounted in its owning campus tab. `POST /v1/apps/calls/attach` requests access to that tab's accepted call. The response is `{ "grantId": "...", "status": "approval" | "active" }`. Zetapad shows **Use this call in this app** before providing roster or tiles. Approval lasts for that participation while app consent and configuration remain valid. Each member approves independently. A reload starts with native calls, a fresh launch, and fresh backend login. Use the token only from your backend. All endpoints recheck scopes, app publication, current consent, launch, tab, and participation. Disconnecting or deleting an app, changing permissions, leaving a call, or losing login access revokes presentation. Renew login with a fresh normal authorization code before the 900-second token expiry. Renewal in the same launch preserves the live call. Developers can edit published apps. A registration save invalidates old launches and credentials; stop polling and clear stale call bindings. Reopen for a fresh launch and backend login. Scope or origin changes require updated member consent and native call approval before new presentation. See [scope changes and reauthentication](https://zetapad.com/developers/docs/authentication#scope-changes-and-reauthentication). The native call continues during recovery. ## Silent call availability After normal login and scope consent, call `GET /v1/apps/calls/status` from your backend with `calls:read`. This check requires no **Use this call in this app** approval and shows no prompt. It creates no grant or command, renews no leases, and changes no presentation, devices, or media. | Situation in the owning tab | Response | | --- | --- | | No accepted, unexpired call participation | `{ "inCall": false, "canAttach": false, "access": "unavailable" }` | | Accepted participation without current app approval | `{ "inCall": true, "canAttach": true, "access": "approvalRequired" }` | | Accepted participation with remembered current app approval | `{ "inCall": true, "canAttach": true, "access": "approved" }` | `inCall` means an accepted call with valid participation in this launch's owning campus tab. Pending incoming or outgoing invitations do not count. An accepted call in another tab is not reported. The endpoint accepts no user, call, participation, or tab selector and returns only these three fields: no IDs, roster, identities, media status, or credentials. Invalid login, stale launches, revoked consent, or expired mounts return normal errors rather than an unavailable result. Use `canAttach` to offer **Use current call in this app**. On the member's choice, request `/attach`; Zetapad then asks for approval if needed. `approved` describes remembered permission only: it does not create an active grant or hand over presentation. Always attach and prepare before showing tiles. Availability is advisory and can change immediately; `/attach` independently rechecks current authorization and participation. With `calls:control`, an unavailable result can accompany an option to start a call through native controls. A pending invitation or call in another tab may still prevent starting; never switch or leave a call automatically. Recheck status after native acceptance. Keep these requests uncached and check when entering or returning to the call view; avoid a tight polling loop. ## Implementation sequence 1. Register the call scopes and configure [hosted tile embedding](https://zetapad.com/developers/docs/embedding#hosted-call-tiles). 2. Complete `ready`, `authenticate`, and the backend token exchange using the [browser](https://zetapad.com/developers/docs/examples/browser) and [server](https://zetapad.com/developers/docs/examples/server) examples. Retain the exact responding parent origin for tile URLs. 3. Read `/status` silently and offer **Use current call in this app** when `canAttach` is true. Do not call `/attach` merely to check availability. 4. On the member's choice, request `/attach`. With control access, `/start` or `/accept` can open native controls when needed; wait for an `applied` outcome and accepted participation before attaching. An outgoing invitation alone is unavailable. Never replace another active call automatically. 5. Store the returned grant in the visitor's server session and poll `/grants/{grantId}` until native approval returns `active`. An approval response contains no roster. Stop on denial or authorization failure. 6. Prepare a presentation, request the current speaker tiles your layout needs, mount their hosted iframes, and request commit. Audio-only preparation requires no tiles. 7. Drive a heartbeat from the app browser every five seconds, renew normal login before expiry, and release when the visitor leaves the view. Restart preparation after restoration or reload. The [hosted call example](https://zetapad.com/developers/docs/examples/calls) includes executable helpers and a mapping from app-owned routes to these endpoints. Your app backend resolves grants, presentations, and command IDs from its authenticated session. It does not accept those bindings or bearer tokens as authorization from browser parameters. Keep state separate for each mounted launch when a visitor has multiple tabs. ## Public call state `GET /v1/apps/calls/grants/{grantId}` returns approval status. Once active it includes `callId`, `participationId`, `hostUserId`, role, stage state, capacity, the whole public `participants` roster, and native media status. Poll through your backend for updates. Each participant contains permanent `userId`, current `participationId`, `username`, `avatarUrl`, `role`, `presence`, and `stageState`. Presence is `connected`, `reconnecting`, or `leaving`. The call supports 100 members with six speaker seats. Private profile fields remain under their existing scopes. Responses contain no Firebase UID, credential, room token, or track. Local media status has camera, microphone, playback mute, connection, and up to six speaker media flags. Use `userId` for saved app data and recipient hints. Use `participationId` for tiles and stage moderation `targetId`; it changes when the member rejoins. Treat usernames and avatars as display data. Refresh the roster before preparing a new subset, and tolerate speakers departing between roster and tile requests. ## Hosted presentation 1. `POST /v1/apps/calls/grants/{grantId}/presentations` with `{ "mode": "video" }` or `{ "mode": "audioOnly" }` returns `presentationId` and `status: "preparing"`. 2. For video, `POST /v1/apps/calls/presentations/{presentationId}/tiles` with `{ "participationId": "...", "fit": "cover", "label": true }` returns `tileId` and `url`. Fit can be `cover` or `contain`. Only current accepted speakers are eligible, including self-view from already authorized call video. Each presentation permits six unique tiles. 3. Return those URLs from your backend and mount ordinary iframes. Resolve the relative URL against the **actual campus origin that answered the existing ready handshake**. The tile and the owning campus window must have the exact same origin, including port. Do not proxy tile documents through your app origin. 4. `POST /v1/apps/calls/presentations/{presentationId}/commit` requests the handoff. Its initial response has `status: "commitRequested"`; Zetapad waits for its trusted tile code to report a rendered video frame or an appropriate placeholder. Every selected tile must be ready and visible. Video mode requires at least one ready tile when the stage has speakers. Native presentation stays usable while preparing. 5. Send `POST /v1/apps/calls/presentations/{presentationId}/heartbeat` every five seconds from your backend while its visitor session remains active. The browser can call an ordinary protected route on your own backend to drive this lease; no calls messaging library is needed. An unattended server timer should not continue claiming a failed visitor is active. 6. `POST /v1/apps/calls/presentations/{presentationId}/release` returns presentation to Zetapad. Released IDs cannot commit again; prepare a fresh presentation to change the tile subset. A heartbeat response reports the presentation status, including `app` after trusted handoff and `released` after release. A restored presentation rejects further heartbeats; clear it from your session and prepare again after valid call access. The app can resize, position, clip, and round iframe containers, including circular tiles. Hosted cropping and labels remain native. Existing speaker audio stays in Zetapad, even when the app selects two tiles or audio-only mode. No second room, device capture, audio renderer, or media publication is created during handoff. Tiles use one-use bootstraps, same-origin document access, and exact window binding. They contain no auth provider, LiveKit connection, or capture. They attach existing video directly; tracks are never sent through `postMessage`. Tile embedding headers allow only the registered app and the tile's Zetapad origin. Your page CSP must permit that campus origin in `frame-src`. Mount tiles directly inside the registered app iframe, using an ordinary unsandboxed child iframe; do not remove its same-origin capability with an additional sandbox. Do not reuse a tile URL after reload or put it in a different window. Ordinary campus pages remain unembeddable. One persistent native call panel holds camera/microphone status, playback mute, Leave, and **Return call to Zetapad**. It stays available during preparation and becomes a compact overlay after app handoff, releasing sidebar space. Tablets and larger screens support native left/right docking; phones float the same controls along the selected edge. Release, exit, reload, authorization loss, invalid tiles, or fifteen seconds of missed foreground health restores native presentation. Video mode also restores if required tiles stay invisible or unready for fifteen seconds, even while their heartbeat continues. The timeout pauses while the campus tab is hidden, with a fresh grace period when shown. Capture and membership continue during presentation changes. Playback mute survives refresh for the same participation and clears on departure. Trusted readiness proves that the tile surface works; it cannot prove your CSS has left it unobscured. Keep tiles visible and preserve the native return control. Zetapad renews its native launch lease independently of roster polling, media reports, and call commands. Hidden/shown transitions update the native lease even while those requests are pending. App presentation heartbeats remain your backend's responsibility: keep them separate from gameplay requests, bound their duration, and retry temporary network or server failures while the visitor is active. Forward error statuses and codes so real access loss stops the heartbeat. Hidden-tab recovery does not extend backend login expiry or restore revoked permissions. ## Adaptive video quality Zetapad bounds new camera capture to 1280 × 720 at up to 24 fps, allowing lower resolutions or frame rates when the device cannot provide them. Native calls, private preview, and app calls share that camera track. Handoffs, resizing, and audio-only viewing do not reacquire devices or change outgoing camera/microphone choices. For a 1280 × 720 source, VP8 simulcast offers approximate 90p, 360p, and 720p layers where the browser supports them. Their maximum bitrates are 60 kbps, 400 kbps, and 1 Mbps, with frame-rate ceilings of 8, 15, and 24 fps respectively. Lower-resolution sources can have fewer layers; the original source uses the 1 Mbps / 24 fps ceiling. Source aspect ratio and browser capabilities determine actual dimensions. These are encoding ceilings, not guaranteed received quality or total upload limits. Dynacast pauses higher layers when nobody needs them. Hosted tiles report their actual CSS size, visibility, and decoded aspect ratio to Zetapad. Each remote speaker adapts independently at one video pixel per CSS pixel, accounting for `cover` versus `contain`. A small tile receives an available smaller layer; 360p is a usual viewing size rather than a fixed limit. Enlarging a tile can request up to the available 720p source. The shared bandwidth budget caps quality and recovers gradually; resizing cannot bypass that ceiling. Hidden tiles stop receiving video. After successful handoff, the native panel detaches and no longer adds video-size demand. Use the actual visible dimensions for your iframe containers. Avoid oversized surfaces that CSS scales or clips down: arbitrary styling cannot prove how much of a tile the member sees. For a view needing no video, prepare `audioOnly`. This stops incoming video and retains all authorized speaker audio once in Zetapad. It does not turn off outgoing camera video; other call members may still be watching that speaker. No new quality scope, resolution REST parameter, or app-side media SDK is required. A larger native or app viewer elsewhere in the call can keep a publisher's higher layer active. Adaptive receiving and dynacast reduce network/encoding work, while the explicit camera defaults bound capture; tiny local tiles alone do not lower the publisher's camera source resolution. ## Call requests Each start, accept, invite, action, playback, or activity invitation request uses a unique `clientId` of 16–128 URL-safe characters, such as `crypto.randomUUID()`. Retry the same ID with the same payload; changed payloads fail. The response returns `commandId`. Poll `GET /v1/apps/calls/commands/{commandId}` through your backend for `pending`, `working`, `applied`, `declined`, `failed`, or `expired` and optional `result`. Stop at a terminal outcome. Commands expire after one minute. The app command limit is thirty requests per launch per minute; existing call invitation limits remain five per member per minute. | Endpoint | Body and native behavior | | --- | --- | | `POST /v1/apps/calls/start` | `{ clientId, recipientId?, role? }`; native recipient and role confirmation; an existing call is never switched or left automatically | | `POST /v1/apps/calls/accept` | `{ clientId }`; native confirmation of the current incoming invitation | | `POST /v1/apps/calls/grants/{grantId}/commands/invite` | `{ clientId, recipientId?, role? }`; native invitation controls | | `POST /v1/apps/calls/grants/{grantId}/commands/action` | `{ clientId, action, targetId? }`; native interaction and existing server call rules | | `POST /v1/apps/calls/grants/{grantId}/commands/playback` | `{ clientId, muted: true | false }`; local incoming playback only, with visible native status | | `POST /v1/apps/calls/grants/{grantId}/commands/activityInvite` | `{ clientId, activityId }`; native current-member recipient selection | `role` is `speaker` or `audience`; omission retains automatic allocation. An explicit speaker request fails if all six seats are occupied or reserved. Participant 101 is rejected. Online checks, expiration, and retry rules apply. `recipientId` is an optional permanent public `userId`; it preselects a recipient in the native sheet. The member confirms recipients and roles there. `/start` and `/accept` require mounted backend login and `calls:control` but no existing call grant. Grant-bound commands require native call-access approval. A queued or applied invitation does not mean its recipient has accepted; read the roster after acceptance. Supported actions: `cameraOn`, `cameraOff`, `unmute`, `mute`, `leave`, `end`, `stage/request`, `stage/approve`, `stage/reject`, `stage/join`, `stage/cancel`, `stage/leave`, `stage/demote`. Camera-on, unmute, joining stage, and call acceptance require native member interaction. Camera-off and mute apply immediately. Only the actual current call host can approve, reject, demote, or end the call. Stage approval reserves a seat; capture starts only after the member joins explicitly. ## Shared app activities Register a backend session using `POST /v1/apps/calls/activities` with `{ "grantId": "...", "sessionKey": "server-owned-match-key", "label": "Math matchup" }`. The opaque `activityId` belongs to this app and call. Session keys are 1–256 characters and labels 1–80. Activity URLs and user-supplied keys do not authorize entry. `GET /v1/apps/calls/activities/{activityId}` returns the label and call ID only after current per-call approval and matching participation. It requires `calls:control` and never returns the session key. Any accepted member with app call control access can offer the activity through `commands/activityInvite`. Zetapad defaults to the other current members and allows recipient selection. Offers have a separate five-offers-per-member-per-minute limit; recipient invitations expire after five minutes. Recipients may accept or decline independently, and the original call continues. Acceptance opens the registered app and binds the invitation to the new launch. Complete normal backend login, attach, and obtain native call-access approval. Then call `GET /v1/apps/calls/activity-entry` from your backend to obtain verified `{ entryId, activityId, sessionKey, label, userId, expiresIn }`. Use that verified `userId` and server-returned session key to join your own multiplayer session. Never authorize entry from a URL, activity ID, or browser-supplied key. A current entry lease lasts twenty seconds and must be rechecked while protected activity actions continue. Departure, app exit, permission loss, or lease expiry ends access; after expiry the member must accept a current invitation again. Each member independently decides whether the app takes over their presentation. Your backend owns game state, matchmaking, synchronization, and stored activity data. ## Errors and recovery Failures return `{ "error": { "code": "...", "message": "..." } }` with `Cache-Control: private, no-store`. Keep your own app-call routes uncached as well. | Response | App behavior | | --- | --- | | `401 INVALID_TOKEN` | Complete fresh normal authentication; do not keep polling with expired login | | `403 ACCESS_REVOKED` or `APP_UNAVAILABLE` | Stop heartbeat and protected activity actions, clear stale bindings, and let native recovery proceed | | `403 FORBIDDEN` | Check scopes, approval, current tab participation, role, or activity invitation; show the returned message | | `400 BAD_USER_INPUT` | Correct the request, speaker selection, or reused `clientId` payload | | `409 CALL_CONFLICT` | Refresh current call state; leave any existing call to the member's explicit decision | | `429 RATE_LIMITED` | Back off; do not automatically replay an invitation under a new ID | | `503` | Show a retry state and keep native controls usable | On app reload, run the existing ready/login sequence again, attach, and build a fresh presentation. Do not replay stored tile URLs or commit a released ID. Test actual nested video playback, camera-off placeholders, cover/contain, resize, two-tile subsets, audio-only, Return, app exit, heartbeat loss, and token renewal in Chrome and Firefox before publishing your integration. --- # Hosted call example A backend REST integration and plain iframe layout with no calls SDK. Source: https://zetapad.com/developers/docs/examples/calls ## Set up the app Register `calls:read`, plus `calls:control` for actions and activity offers. Calls are available by default, and each member approves your app's access to their current call. Follow the [calls guide](https://zetapad.com/developers/docs/calls) for the full REST contract and [embedding requirements](https://zetapad.com/developers/docs/embedding#hosted-call-tiles) for hosted tiles. Complete the existing version 2 `ready` and backend login first. Keep the browser example's verified `parentOrigin` as `campusOrigin`. The server example stores `session.zetapadAccess = { token, expiresAt }`; the helpers below use that same server-only state. Supply your framework's routing, session persistence, error responses, and CSRF middleware. ## Backend REST helper Adapt this helper to your normal server session after the [existing login](https://zetapad.com/developers/docs/examples/server). Resolve the access token from the verified visitor session; never accept an app token from a browser parameter. Apply your existing CSRF checks to state-changing browser routes. ```js async function callApi(session, path, body) { const access = session.zetapadAccess; if (!access || Date.now() >= access.expiresAt) { throw new Error("Authenticate in the iframe again."); } const response = await fetch(`https://zetapad.com/v1/apps/calls${path}`, { method: body === undefined ? "GET" : "POST", headers: { Authorization: `Bearer ${access.token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", }); const result = await response.json(); if (!response.ok) { const error = new Error(result.error?.message ?? "Call access ended."); error.status = response.status; error.code = result.error?.code; throw error; } return result; } ``` ## App-owned routes These example routes belong to your app. All Zetapad paths in the table are relative to `https://zetapad.com/v1/apps/calls`. Resolve the visitor's token and stored IDs from the authenticated server session. Return `Cache-Control: private, no-store` on your responses. | Your app route | Server behavior | | --- | --- | | `GET /my-call/status` | Call `/status` with this launch's server token, return only `inCall`, `canAttach`, and `access`; no attach, grant, or prompt | | `POST /my-call/attach` | Call `/attach`, store `session.zetapadCall = { grantId: result.grantId }`, return approval status | | `GET /my-call/state` | Read `/grants/{session.zetapadCall.grantId}`, return approved public state | | `POST /my-call/prepare` | Validate `mode`, run `prepareCallView`, persist the session, return `{ mode, tiles }` | | `POST /my-call/commit` | Call `/presentations/{storedPresentationId}/commit` with an empty body | | `POST /my-call/heartbeat` | Call `/presentations/{storedPresentationId}/heartbeat` with an empty body, return status | | `POST /my-call/release` | Call `/presentations/{storedPresentationId}/release`, then clear the stored presentation | If the tab has no accepted call, first request `/start` or `/accept` from the backend with a unique `clientId`. Native controls confirm the operation. Store its `commandId` server-side and poll `/commands/{commandId}` until `applied`. After `/start`, wait for the recipient's acceptance before attaching; an applied outgoing invitation is still pending. Stop on `declined`, `failed`, or `expired`; another running call requires the member's own decision. ## Check availability without prompting Create a button with `id="use-current-call"`, initially hidden. After login, this browser code checks only the current launch's owning tab through your protected backend route: ```js // GET /my-call/status calls callApi(session, "/status") on your backend. const response = await fetch("/my-call/status", { method: "GET", cache: "no-store" }); if (!response.ok) throw new Error("Call availability could not be checked."); const status = await response.json(); const useCallButton = document.querySelector("#use-current-call"); useCallButton.hidden = !status.canAttach; // Only the member's button click calls POST /my-call/attach. // Even access: "approved" still needs attachment and a fresh presentation. ``` The result uses `access: "unavailable"`, `"approvalRequired"`, or `"approved"`. Pending invitations are unavailable until accepted. `approved` means permission is remembered for this participation, not that this launch has attached or taken over presentation. Errors must remain errors; do not convert an expired login or revoked access into `inCall: false`. Check when entering or returning to the call view and after native acceptance; avoid tight polling. See [silent call availability](https://zetapad.com/developers/docs/calls#silent-call-availability). Only the member's choice calls `/my-call/attach`, then polls `/my-call/state` while status is `approval`. Display a prompt to approve **Use this call in this app** in Zetapad. Once `active`, request `/my-call/prepare` with `{ "mode": "video" }`; only then mount and commit. Availability may have changed, so attachment can still fail. A denied grant returns an access error, so stop polling and preserve the call. Serialize these requests and keep launch-specific session state separate across tabs. ## Prepare a speaker subset This server helper reads the approved roster and chooses two current speakers. Change the subset to match your layout, up to six unique speakers. Use permanent `userId` for app identity and current `participationId` for tiles. For audio-only, pass `"audioOnly"`; it creates no tiles. ```js async function prepareCallView(session, mode) { if (!["video", "audioOnly"].includes(mode)) throw new Error("Choose video or audioOnly."); const current = session.zetapadCall; if (!current) throw new Error("Attach to the current call first."); const roster = await callApi(session, `/grants/${current.grantId}`); if (roster.status !== "active") throw new Error("Approve call access in Zetapad first."); const presentation = await callApi(session, `/grants/${current.grantId}/presentations`, { mode }); // Persist before mounting so a partial failure can still release the presentation. current.presentationId = presentation.presentationId; const tiles = []; if (mode === "video") { const speakers = roster.participants.filter((member) => member.role === "speaker" && member.presence !== "leaving"); // Choose the speaker subset your layout needs; the maximum is six unique tiles. for (const member of speakers.slice(0, 2)) { tiles.push(await callApi(session, `/presentations/${current.presentationId}/tiles`, { participationId: member.participationId, fit: "cover", label: true, })); } } return { mode, tiles }; // POST /my-call/prepare; no bearer token is returned. } ``` On preparation failure, release any partially prepared presentation using the stored ID, clear it, and refresh call state before retrying. A speaker can leave between the roster and tile requests. ## Mount and commit Your browser receives `{ mode, tiles }` from `/my-call/prepare`. Create a `
` and use `tiles` in the following code. `campusOrigin` is the pinned parent origin from your existing ready handshake; for a `www` campus it must be `https://www.zetapad.com`. Direct tiles use the owning campus origin even when REST requests use the canonical API origin. `yourCsrfHeaders` comes from your app's CSRF middleware. ```js function mountCallTiles(grid, tiles, campusOrigin) { const allowed = ["https://zetapad.com", "https://www.zetapad.com"]; if (!allowed.includes(campusOrigin)) throw new Error("Use the verified parent origin."); for (const tile of tiles) { if (!/^\/call-tiles\/zp_tileboot_[a-zA-Z0-9_-]+$/.test(tile.url)) throw new Error("Invalid hosted tile URL."); const frame = document.createElement("iframe"); frame.src = new URL(tile.url, campusOrigin).href; frame.title = "Call speaker"; frame.referrerPolicy = "no-referrer"; grid.append(frame); } } // campusOrigin is pinned by the existing ready handshake. mountCallTiles(document.querySelector("#call-tiles"), tiles, campusOrigin); // Your own protected backend route calls /presentations/{id}/commit. const commit = await fetch("/my-call/commit", { method: "POST", headers: yourCsrfHeaders, cache: "no-store" }); if (!commit.ok) throw new Error("Call presentation could not be committed."); ``` ```css #call-tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } #call-tiles iframe { width: 100%; aspect-ratio: 1; border: 0; border-radius: 50%; } ``` Commit is an intent. Native video stays visible until all selected trusted surfaces are ready and visible. The initial backend response has `status: "commitRequested"`; later heartbeat responses report `app` after handoff. For audio-only, `tiles` is empty and the same commit route applies. Existing speaker audio continues once in Zetapad for both modes. Mount the iframes as direct children of the registered app document. Set `frame-src` to allow the campus origin and avoid an additional sandbox that removes `allow-same-origin`. Each tile URL is one-use; do not preload it, proxy it, or reuse it after reload. Position, resize, and clip the iframe with your CSS. Size each iframe to the actual tile your layout shows. Zetapad automatically requests an appropriate video layer for its dimensions and `cover`/`contain` cropping. Hidden surfaces pause incoming video, and the shared bandwidth ceiling still applies when resized. For example, a 360 × 360 circular `cover` tile needs more of a 16:9 source than a `contain` tile of the same size. Choose `audioOnly` when the view needs no video; all speaker audio continues and outgoing camera choices are preserved. See [adaptive video quality](https://zetapad.com/developers/docs/calls#adaptive-video-quality) for capture and layer limits. No resolution request or extra scope is needed. ## Keep the visitor presentation alive Have a protected `/my-call/heartbeat` route resolve this visitor's stored presentation and call `/presentations/{id}/heartbeat`. The browser can drive it with ordinary same-app HTTP: ```js let heartbeatPending = false; const heartbeat = window.setInterval(async () => { if (heartbeatPending) return; heartbeatPending = true; try { const response = await fetch("/my-call/heartbeat", { method: "POST", headers: yourCsrfHeaders, cache: "no-store", signal: AbortSignal.timeout(8000) }); if (!response.ok && response.status < 500 && ![408, 429].includes(response.status)) { window.clearInterval(heartbeat); // Clear stale presentation state and show the access error. } } catch { // A network timeout is temporary; retry while the visitor is still in this view. } finally { heartbeatPending = false; } }, 5000); ``` Clear the timer and call `/my-call/release` on the explicit exit or layout change. Remove the old iframes after release and request a fresh presentation for a different subset or mode. Do not rely on unload delivery; a crash or fifteen seconds of missed foreground heartbeat restores native calls. The native **Return call to Zetapad** control stays accessible above the app. Keep heartbeat work separate from gameplay requests and avoid overlapping heartbeat requests. Forward Zetapad's error status and code through your protected route. Retry network timeouts, `408`, `429`, and server failures while the visitor stays in this view; repeated failures still restore native calls after the foreground deadline. On access loss or a released presentation, stop the timer, clear the stale presentation, and show the server error. Reload starts with native presentation and requires fresh ready/login, attachment, and tile URLs. Renew the normal login before its 900-second expiry using the same launch; valid renewal preserves the existing call and presentation. Never keep a background server timer claiming a closed visitor is still active. ## Offer a math matchup ```js const activity = await callApi(session, "/activities", { grantId: session.zetapadCall.grantId, sessionKey: yourBackendMatch.id, label: "Math matchup", }); await callApi(session, `/grants/${session.zetapadCall.grantId}/commands/activityInvite`, { clientId: crypto.randomUUID(), activityId: activity.activityId, }); // In each recipient's backend after invitation acceptance, normal login, and call approval: const entry = await callApi(session, "/activity-entry"); await joinYourMatch(entry.sessionKey, entry.userId); ``` The native sheet confirms recipients from the current call. Offers are limited to five per member per minute; invitations expire after five minutes. Acceptance opens the app; each recipient completes normal login, attaches, and approves access before their backend reads `/activity-entry`. Use only the returned server-verified context to enter the match. Keep `sessionKey` on your backend, and resolve app identity from the verified `userId`. Recheck `/activity-entry` during protected match actions before its twenty-second lease expires. A URL or browser-supplied match key cannot authorize entry. Stop protected activity actions on departure, revocation, or expiry. Each visitor decides whether to display the call inside the app; declining an offer or returning to native presentation preserves the call. --- # Iframe protocol The version 2 postMessage contract for readiness and code-only authorization. Source: https://zetapad.com/developers/docs/protocol ## Transport and trust Use `window.parent.postMessage` with an exact trusted Zetapad origin. Install a message listener before sending `ready`. Verify `event.source === window.parent` and the exact `event.origin`, then pin the responding parent origin after the readiness handshake. Production parents are `https://zetapad.com` and `https://www.zetapad.com`. Use a fresh request ID for each request. Untrusted or malformed messages receive no response. Version 2 supports only `ready` and `authenticate`. Member IDs and profile fields are available through backend REST authentication, including for apps with no scopes. ## Request envelope ```json { "channel": "zetapad", "version": 2, "requestId": "unique-id", "method": "ready", "params": {} } ``` `channel` must be `zetapad`, `version` must be `2`, and `requestId` must be a nonempty string of at most 128 characters. `params` must be an object. Unknown top-level fields, method names, and extra params are rejected. The app identity and scopes come from the Zetapad registration; requests cannot override them. See [the JSON Schema](https://zetapad.com/developers/docs/protocol.schema.json) for machine-readable message shapes. ## ready Call with `params: {}` after the iframe loads. A successful result is: ```json { "launchId": "zp_launch_...", "appId": "zp_app_..." } ``` Keep the launch ID for subsequent calls. It belongs to the currently mounted room; it is not an app login token. Launches expire after one hour, and authentication may extend the stored launch lifetime. Reopen the room when a launch is stale or expired. Zetapad shows retry controls after 15 seconds without a valid readiness handshake. Install the listener and send `ready` promptly. ## authenticate Call after `ready` with the current launch ID and a backend-generated state challenge: ```json { "launchId": "zp_launch_...", "state": "random-session-bound-challenge" } ``` `launchId` is a nonempty string of at most 128 characters and must match the current launch. `state` must be a nonempty string of at most 256 characters. A successful result is: ```json { "code": "zp_code_...", "expiresIn": 60, "state": "random-session-bound-challenge" } ``` The member grants every registered scope or cancels. Saved consent is reused when it remains valid. Wait for the pending consent or authentication request rather than starting another one. On cancellation, the member receives `CONSENT_DENIED` and Zetapad closes the room. The result contains only `code`, `expiresIn`, and `state`. A successful reply means the member authorized a code; your app is not logged in until its backend completes verification. Your backend must validate and consume the echoed state against the same visitor session before exchanging the code. The code expires after 60 seconds and can be exchanged once by its registered app using its client ID and secret. REST userinfo then returns the shared server `userId`, granted scopes, and approved fields. This flow is required even with zero scopes. See [authentication](https://zetapad.com/developers/docs/authentication). ## Response envelope Success replies contain exactly one `result`; failure replies contain exactly one `error`. Both echo `channel`, `version`, and `requestId`: ```json { "channel": "zetapad", "version": 2, "requestId": "unique-id", "error": { "code": "STALE_LAUNCH", "message": "Complete the ready handshake for this room first." } } ``` Correlate each reply with its pending request ID and ignore replies for unknown requests. Handle errors by their stable `code`; messages explain the issue to the developer. See [errors and troubleshooting](https://zetapad.com/developers/docs/troubleshooting) for recovery actions. --- # REST API Exchange an authorization code and fetch the member fields approved for your app. Source: https://zetapad.com/developers/docs/api-reference ## Base URL and authentication The production API base URL is `https://zetapad.com`. Every app completes login through these endpoints from its backend over HTTPS, including with no scopes. Client credentials alone cannot access a member: first obtain that member's one-use code through iframe consent. Do not expose client secrets or bearer tokens to the iframe. The [OpenAPI document](https://zetapad.com/developers/docs/openapi.json) describes login and [app call endpoints](https://zetapad.com/developers/docs/calls). The management dashboard uses separate member APIs. ## POST /v1/apps/token Exchange a one-use authorization code obtained from iframe `authenticate`. Validate and consume the visitor's session-bound state challenge before making this call. ```http POST https://zetapad.com/v1/apps/token Authorization: Basic Content-Type: application/json { "code": "zp_code_..." } ``` The JSON body must contain only `code`, a nonempty string of at most 128 characters. The code is valid for 60 seconds and can be used once by the registered app that received it. Invalid credentials, codes, or ended access are rejected. Successful response (`200`): ```json { "access_token": "zp_token_...", "token_type": "Bearer", "expires_in": 900, "scopes": ["profile:read"] } ``` Keep `access_token` in your backend's visitor session and record its expiry. There are no refresh tokens; use `authenticate` in the iframe to obtain another code. ## GET /v1/apps/userinfo Read the verified shared Zetapad member ID and approved fields: ```http GET https://zetapad.com/v1/apps/userinfo Authorization: Bearer zp_token_... ``` Successful response (`200`) with `profile:read` only: ```json { "userId": "zp_member_...", "scopes": ["profile:read"], "username": "member_01", "name": "Member", "countryCode": "IN" } ``` `userId` and `scopes` are always included, including with `scopes: []`. `userId` is Zetapad's server-generated primary member ID, shared across apps. This endpoint supplies verified identity after credential exchange. Use the server-verified `userId` to key experiences across apps. With no scopes, the complete response is: ```json { "userId": "zp_member_...", "scopes": [] } ``` `profile:read` adds `username`, `name`, and `countryCode`; `birthdate:read` adds `birthdate` in `YYYY-MM-DD` format. Unapproved fields are omitted. Every member has a username; name, country, and birthdate can be null. Usernames are unique, lowercase handles of 3–30 letters, numbers, or underscores and can change anytime. A released username can belong to another account. Use the permanent `userId` for saved data and the current `username` for display. See [permissions](https://zetapad.com/developers/docs/authentication#permissions-and-profile-fields). Current consent and app access are checked on every userinfo read. Call it before protected app actions that rely on Zetapad access. On failure, stop the action and end the app session's Zetapad access. Resolve identity from your server session rather than a `userId` in the request body. You may return approved UI fields to the browser after verification. Call scopes add a public call roster and hosted presentation after native per-call approval. App storage remains on your backend. Member and admin GraphQL APIs do not accept app credentials or app access tokens. ## GET /v1/apps/calls/status Silently check for an accepted call in this app launch's owning campus tab before asking to use it. Requires completed backend login, `calls:read`, current app consent, and a valid native mount; no per-call approval is needed. Send the app's server-held bearer token, with no request body or selectors: ```http GET https://zetapad.com/v1/apps/calls/status Authorization: Bearer zp_token_... ``` Example response (`200`): ```json { "inCall": true, "canAttach": true, "access": "approvalRequired" } ``` `access` is `unavailable`, `approvalRequired`, or `approved`. An unavailable result has both booleans false; accepted participation has both true. Pending invitations and calls in other tabs are unavailable. The response contains only these three fields and creates no grant, prompt, or presentation. Invalid login, revoked consent, or stale mounts return errors rather than false availability. Offer **Use current call in this app** when `canAttach` is true. On the member's choice, request `POST /v1/apps/calls/attach`; it independently rechecks access and requests native approval when needed. Remembered `approved` access still requires attachment and presentation preparation. See the [availability contract](https://zetapad.com/developers/docs/calls#silent-call-availability) and [tested example](https://zetapad.com/developers/docs/examples/calls#check-availability-without-prompting). ## Error responses ```json { "error": { "code": "ACCESS_REVOKED", "message": "App access has ended. Authenticate again to continue." } } ``` | HTTP status | Code | Meaning | | --- | --- | --- | | `400` | `BAD_USER_INPUT` | Invalid JSON token-exchange body or unsupported fields | | `401` | `INVALID_CLIENT` | Client ID or secret could not be verified | | `401` | `INVALID_TOKEN` | Code or access token is invalid, expired, or already used | | `403` | `ACCESS_REVOKED` | Member consent or issued access ended | | `403` | `APP_UNAVAILABLE` | The app can no longer be launched or used in this context | | `403` | `FORBIDDEN` | A required call scope or permission is missing | | `503` | `INTERNAL_SERVER_ERROR` | Temporary app-service failure | Success and error responses use `Cache-Control: private, no-store`. Do not cache them or log credentials. For recovery steps, see [errors and troubleshooting](https://zetapad.com/developers/docs/troubleshooting). --- # Errors and troubleshooting Recover from iframe, authentication, credential, and embedding failures. Source: https://zetapad.com/developers/docs/troubleshooting ## Iframe does not load Check the app document's HTTP response headers. `frame-ancestors` must allow both Zetapad production origins. Remove conflicting `X-Frame-Options: DENY` and `SAMEORIGIN` headers. A CSP meta tag cannot configure `frame-ancestors`. Check the final URL after redirects. Its origin must match the registered launch origin. Local HTTP previews may need an HTTPS tunnel inside an HTTPS parent. See [embedding](https://zetapad.com/developers/docs/embedding). ## Room waits for readiness Install the listener before calling `ready`. Send the version 2 request envelope, empty params, and a unique request ID. Check the exact target origin, reply origin, and `event.source === window.parent`. Zetapad shows retry controls after 15 seconds without readiness. The browser example's individual request timeout is two minutes to allow time for consent; that example timeout is not the room's readiness timer. ## Protocol errors | Code | Recovery | | --- | --- | | `CONSENT_DENIED` | Member cancelled; stop initialization. Zetapad closes the room. | | `AUTHENTICATION_REQUIRED` | The bridge could not issue an approved code; stop initialization and obtain consent again. | | `ACCESS_REVOKED` | Reopen after a registration update, then authenticate and obtain new consent if requested. | | `STALE_LAUNCH` | Reopen the room and complete a new `ready` handshake. | | `INVALID_TOKEN` | Reopen the room if its launch expired; obtain fresh authentication if issued access expired. | | `REQUEST_IN_PROGRESS` | Wait for the pending authentication or consent request. | | `DUPLICATE_REQUEST` | Use a new request ID; do not replay the old request. | | `APP_UNAVAILABLE` | Check publication, deletion, and whether the current member may preview the app; reopen when available. | | `API_ERROR` | The bridge could not classify the failure; stop initialization and retry when services recover. | | `INTERNAL_SERVER_ERROR` | Temporary backend failure; stop the current operation and retry later. | Malformed and untrusted requests receive no response. Inspect your envelope, allowed params, registered origin, and source window if a request times out. ## Where to read member identity Version 2 `authenticate` returns only `code`, `expiresIn`, and `state`. Your backend must exchange the code with its client ID and secret, then call userinfo to retrieve the verified `userId` and approved fields. This applies even with `scopes: []`. Complete the [authentication flow](https://zetapad.com/developers/docs/authentication) before starting the app. ## REST errors `INVALID_CLIENT` requires checking the backend's client ID and current secret. Do not retry with a secret that was rotated or deleted. `INVALID_TOKEN` requires a fresh code or token. Authorization codes expire after 60 seconds and are consumed once. Access tokens expire after 15 minutes. There is no refresh-token endpoint. `ACCESS_REVOKED` and `APP_UNAVAILABLE` require ending protected app access until the member can reopen and authenticate. Registration saves, unpublishing, deletion, rotation, and disconnection invalidate issued access immediately. A scope or launch-origin change also requires fresh member consent; follow [scope changes and reauthentication](https://zetapad.com/developers/docs/authentication#scope-changes-and-reauthentication). `BAD_USER_INPUT` requires fixing the token request JSON. `INTERNAL_SERVER_ERROR` is a temporary service failure. Handle errors by their stable codes, not message text. ## App login fails in an iframe Confirm that the state challenge and login request use the same server-side visitor session. A mismatched, expired, or consumed state must reject login. The browser must never supply its own server-session identity. Check your app's embedded session mechanism. Secure cross-site cookies generally require `SameSite=None`, but browsers may restrict them. Zetapad's own authentication does not depend on third-party cookies; your app's session configuration still matters. ## Direct access still works `frame-ancestors` alone does not block a top-level browser tab. Apply the document guard and browser startup check in [restrict app access](https://zetapad.com/developers/docs/access-control), including to nested routes. If direct API requests work without login, add a current server-session and userinfo check to every protected handler. A valid `ready` result, query parameter, referrer, or browser-supplied profile cannot authorize backend access. --- # Browser example A raw JavaScript integration that verifies the parent, authenticates, and starts your app. Source: https://zetapad.com/developers/docs/examples/browser ## Where this runs Run this code inside your hosted app iframe. Your app must first allow Zetapad in its `frame-ancestors` header. Supply `startYourApp` and `showLoginError`, and connect `/zetapad/state` and `/zetapad/login` to the [server example](https://zetapad.com/developers/docs/examples/server). Keep protected UI hidden while loading. `startYourApp` runs only after a valid parent handshake and successful backend login. If `showLoginError` is called, display the error and leave the app blocked. The version 2 example rejects top-level startup, checks both fixed parent origins and the source window, pins the responding origin, validates code-only replies, and binds login to a backend-generated state challenge. Identity and approved display fields come from your backend after code exchange and userinfo. Use the backend-verified `member.userId` for persistent experiences across apps, including when no scopes are registered. It is raw JavaScript, not an SDK. ## Complete browser example ```javascript // Run inside the app iframe. Trust only Zetapad's two production origins. const ZETAPAD_ORIGINS = ["https://zetapad.com", "https://www.zetapad.com"]; let parentOrigin = null; const pending = new Map(); window.addEventListener("message", (event) => { const reply = event.data; if (event.source !== window.parent || !ZETAPAD_ORIGINS.includes(event.origin) || (parentOrigin && event.origin !== parentOrigin) || !reply || typeof reply !== "object" || Array.isArray(reply) || reply.channel !== "zetapad" || reply.version !== 2 || typeof reply.requestId !== "string" || ("result" in reply) === ("error" in reply)) return; const request = pending.get(reply.requestId); if (!request) return; if (reply.error && (typeof reply.error.code !== "string" || typeof reply.error.message !== "string")) return; if ("error" in reply && !reply.error) return; if (request.method === "ready" && !reply.error) { if (typeof reply.result?.launchId !== "string" || !reply.result.launchId || typeof reply.result.appId !== "string" || !reply.result.appId) return; parentOrigin = event.origin; // Pin all further messages to this exact parent. } if (request.method === "authenticate" && !reply.error && (typeof reply.result?.code !== "string" || !reply.result.code || reply.result.code.length > 128 || reply.result.expiresIn !== 60 || typeof reply.result.state !== "string" || !reply.result.state || reply.result.state.length > 256 || Object.keys(reply.result).some((key) => !["code", "expiresIn", "state"].includes(key)))) return; pending.delete(reply.requestId); clearTimeout(request.timeout); if (reply.error) request.reject(Object.assign( new Error(reply.error.message), { code: reply.error.code })); else request.resolve(reply.result); }); function callZetapad(method, params) { if (method !== "ready" && !parentOrigin) { return Promise.reject(new Error("Complete the ready handshake first")); } const requestId = crypto.randomUUID(); return new Promise((resolve, reject) => { // Allow the member time to read and approve the consent dialog. const timeout = setTimeout(() => { pending.delete(requestId); reject(new Error("Zetapad did not respond. Retry or reopen the room.")); }, 120000); pending.set(requestId, { method, resolve, reject, timeout }); // Only the matching origin receives ready; later calls use the pinned origin. for (const origin of parentOrigin ? [parentOrigin] : ZETAPAD_ORIGINS) { window.parent.postMessage({ channel: "zetapad", version: 2, requestId, method, params }, origin); } }); } async function enterRoom() { if (window.self === window.top) { throw new Error("Open this app from Zetapad Rooms."); } const { launchId } = await callZetapad("ready", {}); // The app backend stores state in the visitor's server-side session. const stateResponse = await fetch("/zetapad/state", { credentials: "same-origin", cache: "no-store" }); if (!stateResponse.ok) throw new Error("App login challenge failed"); const challenge = await stateResponse.json(); const authorization = await callZetapad("authenticate", { launchId, state: challenge.state }); if (authorization.state !== challenge.state) throw new Error("State mismatch"); const response = await fetch("/zetapad/login", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: authorization.code, state: authorization.state }) }); if (!response.ok) throw new Error("App login failed. Authenticate again."); const member = await response.json(); if (typeof member.userId !== "string" || !member.userId || !Array.isArray(member.scopes)) { throw new Error("App backend did not return a verified member"); } // IDs and profile fields come from the backend after verified REST login. // Protected server actions must resolve userId from the session, not the body. return { member }; } enterRoom().then(startYourApp).catch(showLoginError); ``` ## Server requirements `GET /zetapad/state` returns `{ "state": "..." }` for the current visitor's server-side session. `POST /zetapad/login` receives `{ "code": "...", "state": "..." }` and returns the verified member only after state validation, code exchange, and userinfo read. Those routes belong to your app. Do not return secrets or bearer tokens to the browser. Add framework-appropriate error handling and a supported embedded session mechanism. This example returns the verified member for UI use. Returning IDs to your UI is optional; protected backend routes must resolve member identity through the server session even if the browser sends an ID in a request. For local Zetapad at `http://localhost:3000`, replace the trusted origin list with `["http://localhost:3000"]`. Production uses the fixed origins in the example. ## Protect direct access The top-level check prevents browser startup outside an iframe. Apply the [document guard and backend access policy](https://zetapad.com/developers/docs/access-control) as well: this browser check cannot protect APIs or private data by itself. --- # Server example Node.js handlers for session-bound state, confidential token exchange, and protected member reads. Source: https://zetapad.com/developers/docs/examples/server ## Where this runs Use these handlers inside an existing Node.js server or serverless backend with server-side session middleware. Backend login is required for every app, including with zero scopes. Set `ZETAPAD_CLIENT_ID` and `ZETAPAD_CLIENT_SECRET` on that backend. The production REST origin is fixed as `https://zetapad.com`. Resolve the visitor's session through your server middleware. Bind the challenge to the same session used for login, persist session changes, and return `Cache-Control: no-store` on state, login, and protected responses. Never accept session identity from the request body or share one state value across visitors. Use HttpOnly, Secure cookies on HTTPS and configure a session mechanism that works in your embedded context. Add CSRF protection for state-changing routes. Token exchange and userinfo calls must stay on the backend. ## Complete server example ```javascript import { randomBytes } from "node:crypto"; const ZETAPAD_ORIGIN = "https://zetapad.com"; const CLIENT_ID = process.env.ZETAPAD_CLIENT_ID; const CLIENT_SECRET = process.env.ZETAPAD_CLIENT_SECRET; // session is the visitor's server-side session, loaded from a secure cookie. // It must never come from the request body. Return responses with no-store. export function createState(session) { const state = randomBytes(32).toString("base64url"); session.zetapadChallenge = { state, expiresAt: Date.now() + 300000 }; return { state }; // GET /zetapad/state } export async function loginWithZetapad(session, body) { delete session.zetapadAccess; // Failed reauthentication must not retain access. const challenge = session.zetapadChallenge; delete session.zetapadChallenge; // A challenge is used only once. if (!challenge || Date.now() >= challenge.expiresAt || typeof body.state !== "string" || body.state !== challenge.state || typeof body.code !== "string" || !body.code.length || body.code.length > 128) { throw new Error("Invalid login state"); } const basic = Buffer.from(CLIENT_ID + ":" + CLIENT_SECRET).toString("base64"); const response = await fetch(ZETAPAD_ORIGIN + "/v1/apps/token", { method: "POST", headers: { Authorization: "Basic " + basic, "Content-Type": "application/json" }, body: JSON.stringify({ code: body.code }) }); if (!response.ok) throw new Error("Code expired, revoked, or invalid"); const token = await response.json(); session.zetapadAccess = { token: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 }; return readZetapadMember(session); // POST /zetapad/login; return display fields. } export async function readZetapadMember(session) { const access = session.zetapadAccess; if (!access || Date.now() >= access.expiresAt) { delete session.zetapadAccess; throw new Error("Authenticate in the iframe again"); } // Call this before every protected read or action, including after app login. const response = await fetch(ZETAPAD_ORIGIN + "/v1/apps/userinfo", { headers: { Authorization: "Bearer " + access.token }, cache: "no-store" }); if (!response.ok) { delete session.zetapadAccess; throw new Error("Zetapad access ended. Authenticate in the iframe again."); } return response.json(); // Verified userId, scopes, and approved fields. } ``` ## Connect the handlers | Your app route | Handler | Purpose | | --- | --- | --- | | `GET /zetapad/state` | `createState(session)` | Issue a random, five-minute visitor challenge | | `POST /zetapad/login` | `loginWithZetapad(session, body)` | Consume state, exchange the one-use code, and read the verified member | | Every protected route | `readZetapadMember(session)` before its action | Reject missing, expired, or revoked Zetapad access | Your routing layer should catch handler failures, return an authentication error, persist the cleared session state, and stop the protected action. Do not catch an error and then continue serving protected content. `readZetapadMember` returns the backend-verified `userId` and `scopes` even with no permissions. Use this `userId` as the member key for persistent experiences across apps. Resolve it from userinfo through the server session; never use a caller-supplied ID to authorize saved-data access. The login handler returns this member to the browser for display after verification. You may limit that response to fields your UI needs. The iframe authorization reply contains no identity or profile data; returning display IDs from your own backend does not make them login credentials. ## End access correctly The token expires after 15 minutes. Obtain a fresh code through the iframe rather than attempting a refresh-token exchange. `readZetapadMember` checks current userinfo before returning identity and removes access when userinfo fails. Member disconnection, unpublishing, deletion, and secret rotation invalidate issued access immediately. Your app must stop its own protected reads and actions when checks fail; Zetapad cannot terminate arbitrary sessions stored by your app. ## Restrict document access These handlers protect server-verified member access. To block direct browser navigation, apply the separate [document guard](https://zetapad.com/developers/docs/access-control#restrict-app-document-requests) and use the [browser startup check](https://zetapad.com/developers/docs/examples/browser). For a complete local Zetapad stack, replace `ZETAPAD_ORIGIN` with `http://localhost:3000`. Keep the production value for deployed integrations.