Skip to content

Workspace reviewer

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. The applicant never sees the workspace reviewer; it is for your internal staff or support team.

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:

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

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

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 for the pinned URL shape and the SRI hash from the release notes.

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.

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.

Request body:

{
"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:

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

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

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.

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:

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

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

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:

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.

  • API keys - grant the sessions:workspace_token scope
  • Token handoff - the equivalent flow for the applicant-facing browser token
  • Quickstart - the applicant-facing verification flow