Skip to main content

Embedding and links

Getting the candidate into the assessment.

Two ways in. Pick one.

A link you send

curl -X POST https://hiring-api.experthire.cloud/v1/assessments/$ASSESSMENT_ID/launch-link \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expires_in_seconds": 604800}'

Still a placeholder: $ASSESSMENT_ID, $EH_SECRET_KEY. Add them under Your values above.

201Response
{
  "object": "launch_link",
  "url": "https://room.experthire.io/launch?token=ehs_lt_9Qm2...&api=https%3A%2F%2Fhiring-api.experthire.cloud",
  "assessment_id": "3b8e1d02-5c77-4c2a-8a41-9b2f7e6d4c10",
  "expires_at": 1767830400,
  "livemode": true
}

Single use, seven days by default and seven at most. The URL points at the assessment room we host, and carries the token and the API it was minted from in the query string. It is deliberately not built from a custom domain: those serve the recruiter app, which has no /launch route, so a link built from one would 404. Treat the whole URL as opaque and pass it through unchanged.

The token is hashed at rest, so a database read cannot mint a session from it. Opening it twice returns launch_token_used.

Embedded in your page

Three steps, and the secret key stays on your server throughout.

1. Allowlist your origin, once.

curl -X POST https://hiring-api.experthire.cloud/v1/domains \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin": "https://app.example.com"}'

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

Exact match on scheme, host and port. No wildcards.

This is not CORS. Adding an origin here gates session minting and framing; it will never add an Access-Control-Allow-Origin header to any response.

2. Mint a session on your server, scoped to one assessment, and hand the token to the page. On a human_interview, each interviewer gets their own session: add their interviewer_id.

curl -X POST https://hiring-api.experthire.cloud/v1/sessions \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"assessment_id": "$ASSESSMENT_ID", "origin": "https://app.example.com"}'

Still a placeholder: $EH_SECRET_KEY, $ASSESSMENT_ID. Add them under Your values above.

201Response
{
  "object": "session",
  "session_token": "eyJhbGciOiJFZERTQSIsImtpZCI6...",
  "jti": "c4a1f9e2-8b73-4d16-9f05-2e7c3a8d6b41",
  "expires_at": 1767226500,
  "refresh_after": 1767226320,
  "assessment_id": "3b8e1d02-5c77-4c2a-8a41-9b2f7e6d4c10",
  "environment": "live",
  "livemode": true
}

3. Mount the room with our loader and a publishable key (ehs_pk_...). Create one in Settings under API keys by choosing Publishable key, or with POST /v1/api-keys and "key_type": "publishable". It is safe in browser code: all it can do is look up which origins may frame the room.

npm install @experthireai/room
import { ExpertHire } from "@experthireai/room";

const sdk = await ExpertHire.load({ publishableKey: "ehs_pk_test_..." });
const room = await sdk.mount({
  container: "#interview",
  sessionToken, // from step 2
  interviewId: assessmentId,
});

A test key loads the room from room.sandbox.experthire.io and a live key from room.experthire.io. Use the matching host in the headers below.

The room makes these calls itself with the session token, and refreshes the token before it expires. You only make them if you build your own room:

CallPurpose
GET /v1/sessionWhat to render, including the capability flags below
POST /v1/assessments/{id}/joinRealtime credentials; starts the interview
GET /v1/assessments/{id}/timerServer-authoritative remaining time
POST /v1/assessments/{id}/pauseFreeze the clock when the candidate leaves the page
POST /v1/assessments/{id}/endClose the room
POST /v1/sessions/refreshRefresh the token before refresh_after

join is idempotent. Rejoining after a refresh or a dropped connection returns fresh realtime credentials without restarting the clock or dispatching a second interviewer.

pause applies to AI interviews. The interviewer resumes the clock on the candidate's first turn after they rejoin, and ends an interview left paused past its length plus a short grace period.

In your own room, a mid-interview 401 almost always means the token was not refreshed.

Headers on your own page

Only relevant if you embed. If you send the candidate a link, they are on our page under our own policy and there is nothing for you to configure.

Permissions. This is the most common embedding failure and it fails silently: the candidate sees a black tile and no error. An allow= on an iframe can only delegate what the parent page already has, so if your page sends a Permissions-Policy header at all, it must name us:

Permissions-Policy: camera=(self "https://room.experthire.io"),
                    microphone=(self "https://room.experthire.io")

If you send no Permissions-Policy, delegation works and there is nothing to do.

Content Security Policy. Your policy has to allow framing us and loading the loader:

frame-src  https://room.experthire.io;
script-src https://room.experthire.io;

That is the whole list. The room's own connections, realtime audio and video included, happen inside our frame under our policy, so they are not yours to allowlist.

We deliberately do not send X-Frame-Options on the room. It has no allowlist form, so a page that must be embeddable by named partners cannot send it. Framing is controlled by the origins you register with POST /v1/domains.

Code editor and whiteboard

GET /v1/session and the join grant both return supports_code_editor and supports_whiteboard. Render the matching surface when they are true and post the work back as the candidate goes:

CallPurpose
POST /v1/assessments/{id}/codeSave the editor contents
POST /v1/assessments/{id}/whiteboardSave the whiteboard

These flags come from the round type, not from the assessment. A coding round sets supports_code_editor and a system_design round sets supports_whiteboard; the default general_interview sets neither. If you create an assessment without passing round_type, you get general_interview and no editor. This is the most common reason the editor appears to be missing, and it is not related to sandbox.

Save as you go rather than only at the end. The interviewer scores whatever was last posted, so unsaved work at the final moment is not scored.

Origin is checked every request

The session token travels in a URL fragment, so binding the origin once at mint would let a stolen token replay from anywhere. It is re-checked on every call, against the origin you minted it for or our room hosts, since calls made from inside the frame carry the room's origin. A token copied onto any other page is refused with origin_not_allowed.

Revoking

DELETE /v1/sessions/{jti} ends a session immediately. The row behind the token is what makes that real; the JWT alone would stay valid until it expired.

Next