The workspace reviewer lets your staff review an applicant's verification, evidence, and report without leaving your product. Your backend mints a short-lived token pair server-to-server, and the `./workspace` loader mounts a hosted iframe that renders the reviewer surface.

This is a separate integration from the applicant-facing IDV flow described in the [Quickstart](/developers/sdks/quickstart). The applicant never sees the workspace reviewer; it is for your internal staff or support team.

## 5-minute quickstart

You need three things on screen: one server endpoint that mints the token pair, one `mount` call, and a **sized container**. The reviewer iframe has no built-in width or height, so if you skip the CSS it renders as a tiny box. Size the container and the iframe fills it.

**1. Set your workspace origin once.** In the Checktiv console, go to **Developers -> Workspace origin** and enter the single `https` origin of the page that will frame the reviewer (scheme and host only, for example `https://app.yourproduct.com`, no path and no wildcard). The mint call rejects any `origin` that does not match this value exactly.

**2. Mint the token pair on your server** (never in the browser). This endpoint proxies to Checktiv with your `ah_sk_*` secret key and maps the response into the camelCase shape the loader expects:

```js
// POST /api/checktiv/workspace-token/:sessionId  (your server; ah_sk_* secret key)
app.post('/api/checktiv/workspace-token/:sessionId', async (req, res) => {
  const r = await fetch(
    `https://api.us.checktiv.com/v1/sessions/${req.params.sessionId}/workspace_token`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CHECKTIV_SECRET_KEY}`, // ah_sk_*
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        origin: 'https://app.yourproduct.com', // must match your configured workspace origin
        actor: { extId: 'staff_482', name: 'Jordan Reviewer' },
        capabilities: ['session:read', 'evidence:read', 'report:read'],
      }),
    },
  );
  if (!r.ok) {
    // Surface the platform status (422 bad origin, 403 missing scope, 404
    // unknown session, 429/503 rate limited) instead of mapping a missing body.
    res.status(r.status).json(await r.json().catch(() => ({})));
    return;
  }
  const { data } = await r.json(); // { framing_token, data_token, expires_at }
  if (!data || !data.framing_token || !data.data_token || !data.expires_at) {
    res.status(502).json({ error: 'malformed workspace_token response' });
    return;
  }
  res.json({
    framingToken: data.framing_token,
    dataToken: data.data_token,
    expiresAt: data.expires_at,
  });
});
```

**3. Render the reviewer in your page.** The fastest path is the CDN script tag, which exposes the factory as `Checktiv.workspace(...)` on `window`:

```html
<!-- The container MUST have a real height: the reviewer iframe has NO intrinsic size. -->
<style>
  #review {
    height: 80vh;
  }
  #review > div, /* the loader's wrapper element */
  #review iframe {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
  }
</style>
<div id="review"></div>

<script src="https://sdk.us.checktiv.com/v1/sdk.js" crossorigin="anonymous"></script>
<script>
  const reviewer = Checktiv.workspace({
    region: 'us',
    getToken: async ({ sessionId, reason }) => {
      const res = await fetch(`/api/checktiv/workspace-token/${sessionId}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ reason }),
      });
      return res.json(); // { framingToken, dataToken, expiresAt }
    },
  });

  reviewer.mount('reviewer', {
    sessionId: 'vs_01...', // the session your staff member is reviewing
    target: document.getElementById('review'),
  });
</script>
```

If you build with a bundler instead, import the factory from the `./workspace` entry and keep the same `#review` CSS and the same `getToken` / `mount` from above:

```ts
import { workspace } from '@checktiv/sdk-web/workspace';

const reviewer = workspace({ region: 'us', getToken /* same as above */ });
reviewer.mount('reviewer', { sessionId: 'vs_01...', target: document.getElementById('review')! });
```

That is the whole integration: a configured origin, a sized container, the server mint, and one `mount` call. The rest of this page explains each piece in depth.

The `/v1/sdk.js` tag loads the latest release. For production, pin to a specific version and add a Subresource Integrity hash to guard against CDN compromise; SRI is only coherent on the immutable pinned URL, not the moving `/v1/sdk.js` pointer. See [Versioning](/developers/sdks/versioning) for the pinned URL shape and the SRI hash from the [release notes](/developers/sdks/release-notes).

## How it fits together

```
Your backend                                 Your product (browser)
     |                                              |
     |  (staff member opens a review screen)        |
     |                                              |
     |<-- GET /your-review-page ---- browser loads   |
     |                                              |
     |-- render page --------------------------->   Checktiv.workspace({ getToken, region })
     |                                              |
     |                             loader calls getToken({ sessionId, reason: 'initial' })
     |<-- POST /api/checktiv/workspace-token         |
     |                                              |
     |-- POST /v1/sessions/{id}/workspace_token -> platform (ah_sk_*)
     |<-- { framing_token, data_token, expires_at } - platform
     |                                              |
     |-- return { framingToken, dataToken, expiresAt } -> |
     |                                              |
     |                             loader mounts the hosted iframe,
     |                             delivers dataToken via origin-pinned handshake
```

The `framing_token` is single-use and only drives the iframe's `frame-ancestors` policy; it carries no privileges. The `data_token` authorizes every read and write the reviewer surface makes, and is refreshed automatically by the loader as needed. Neither token is ever written to `localStorage` or exposed on the iframe URL beyond the single-use framing token.

## Backend: mint the token pair

Call this endpoint from your server, authenticated with your `ah_sk_*` secret key. Never call it from the browser.

```
POST /v1/sessions/{id}/workspace_token
```

Requires the `sessions:workspace_token` scope on the API key. Add it to the key in the console under **Developers -> API keys**; see [API keys](/developers/api-keys).

Request body:

```json
{
  "origin": "https://app.yourproduct.com",
  "actor": {
    "extId": "staff_482",
    "name": "Jordan Reviewer",
    "email": "jordan@yourproduct.com"
  },
  "capabilities": ["session:read", "evidence:read", "report:read"],
  "tenantRef": "acct_9182"
}
```

- `origin` (required) must exactly match the workspace origin configured for your organization in the console under **Developers -> Workspace origin**. It is a single `https` origin (scheme and host only, no path and no wildcard) and must be the origin of the page that frames the reviewer iframe. A mismatch, or an unset origin, fails with a `validation_error`.
- `actor` (required) identifies the staff member for display and audit purposes. It is never used as an authorization signal; your backend is responsible for confirming the actor is allowed to review.
- `capabilities` (required, at least one) grants what the reviewer surface can do. Your backend should compute this per staff member, not grant everything by default. Available capabilities: `session:read`, `evidence:read`, `report:read`, `report:read_candidate`, `session:decide`, `session:override`, `telemetry:read`.
- `tenantRef` (optional) partitions the token to one of your own sub-accounts. Omit it for an org-level, unpartitioned token.

`tenantRef` only takes effect if the session was created with a matching `tenant_ref`. To partition reviewer access, set `tenant_ref` on `POST /v1/sessions` when you create the session, then pass that exact same value as `tenantRef` here when you mint the token. Omit `tenant_ref` at both create and mint for an org-level, unpartitioned session. A tenant-scoped token (one carrying a `tenantRef`) only reaches a session whose `tenant_ref` is exactly equal, so a value mismatch between the two, or a tenant-scoped token used against a session created without `tenant_ref`, leaves the session unreachable to that token. An org-level token (no `tenantRef`) reaches any session in the org, partitioned or not.

`tenant_ref` must be an opaque, non-PII handle you control, for example a slug like `"acct_9182"`, never a name, email, or other personal data. It is fixed at create and cannot be changed afterward, so verify the value echoed back in the create response before relying on it: a wrong value silently makes the session unreachable to a tenant-scoped token, and the only recovery is creating a new session. To keep a session at the org level, omit `tenant_ref` entirely; an explicit `null` is rejected by the create request schema.

Response:

```json
{
  "data": {
    "framing_token": "wk_ctx_...",
    "data_token": "wk_...",
    "expires_at": "2026-07-15T18:22:00.000Z"
  }
}
```

The workspace token mints for **any existing session your organization owns, regardless of its status**. This is deliberate: a reviewer needs the token precisely when a session is `awaiting_review` or already `completed`, so there is no workability, terminal-state, or deadline gate. The applicant's own deadline is irrelevant to a reviewer.

Minting fails with `422` if the body is malformed or the supplied origin does not match your configured workspace origin, `403` if the key lacks the `sessions:workspace_token` scope, `404` if the session id does not resolve inside your tenant, and `429`/`503` if the mint rate ceiling is exceeded. Each mint is independent: a repeated request always issues a fresh pair, so do not cache or replay a previous response.

## Frontend: mount the reviewer

Install `@checktiv/sdk-web` and import the `./workspace` entry:

```ts
import { workspace } from '@checktiv/sdk-web/workspace';

const reviewer = workspace({
  getToken: async ({ sessionId, reason }) => {
    // Your backend calls POST /v1/sessions/{id}/workspace_token and
    // returns the pair. Called once on mount and again on refresh.
    const res = await fetch(`/api/checktiv/workspace-token/${sessionId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ reason }),
    });
    return res.json(); // { framingToken, dataToken, expiresAt }
  },
  region: 'us',
});

const handle = reviewer.mount('reviewer', {
  sessionId: 'vs_01...',
  target: document.getElementById('review')!,
  onEvent: (event) => {
    if (event.type === 'checktiv.workspace.navigate' && event.intent === 'session_complete') {
      // The staff member finished the review; navigate away or refresh your list.
    }
  },
});

// Later, when the review screen unmounts:
handle.destroy();
```

`getToken` is your bridge back to the backend mint call: it receives the session id and a reason (`'initial'` on first mount, `'refresh'` when the loader needs a new `data_token`), and must return `{ framingToken, dataToken, expiresAt }` (the camelCase shape your backend maps from the platform's snake_case response). The loader holds the `data_token` in memory only.

`mount('reviewer', options)` takes:

- `sessionId` - the `vs_*` id of the session to review.
- `target` - the `HTMLElement` to mount the iframe into.
- `onEvent` (optional) - receives navigation and error events bridged from the reviewer surface. `event.type` is a forward-open string (`checktiv.workspace.*`), so only branch on the values you recognize and let anything else fall through.

The CDN build exposes the same factory as `Checktiv.workspace({...})` on `window.Checktiv`.

### Sizing the reviewer

The loader appends its iframe with no width or height of its own, so the iframe inherits the browser's default replaced-element size (roughly 300 by 150 pixels) unless you size its container. This is the most common "it mounted but renders tiny" surprise. Give the `target` element a real height and let the iframe fill it:

```css
#review {
  height: 80vh; /* or a fixed height, or a flex child that resolves to one */
}
#review > div, /* the loader wraps its iframe in one child element */
#review iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}
```

The `target` can be any sized box: a `height`-bearing container, a flex or grid child that resolves to a concrete height, or a full-height panel. The reviewer surface is responsive and lays out to whatever box you give it.

### Theming

Pass a bounded, text-only `theme` to `workspace({...})` to brand the reviewer shell with your primary color, color mode, logo, and display name:

```ts
const reviewer = workspace({
  getToken,
  region: 'us',
  theme: {
    primaryColor: '#3B5BDB',
    colorMode: 'auto',
    logoUrl: 'https://app.yourproduct.com/logo.png',
    brandName: 'Your Product',
  },
});
```

Every field is optional and independent. The most common single setting is `colorMode`, which you can pass on its own with no brand color or logo to make the reviewer match your app's light or dark theme:

```ts
const reviewer = workspace({
  getToken,
  region: 'us',
  theme: { colorMode: 'light' },
});
```

`colorMode` accepts `'light'`, `'dark'`, or `'auto'`. `'auto'` is the default and follows the viewer's OS or browser preference, so omit `theme` entirely if that is what you want. `logoUrl` must be an `https://` URL; it renders only as an image in the shell header. `brandName` renders as plain text.

## Related pages

- [API keys](/developers/api-keys) - grant the `sessions:workspace_token` scope
- [Token handoff](/developers/sdks/token-handoff) - the equivalent flow for the applicant-facing browser token
- [Quickstart](/developers/sdks/quickstart) - the applicant-facing verification flow