# 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 `<div id="call-tiles"></div>` 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.
