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.
{
"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.
{
"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:
| Call | Purpose |
|---|---|
GET /v1/session | What to render, including the capability flags below |
POST /v1/assessments/{id}/join | Realtime credentials; starts the interview |
GET /v1/assessments/{id}/timer | Server-authoritative remaining time |
POST /v1/assessments/{id}/pause | Freeze the clock when the candidate leaves the page |
POST /v1/assessments/{id}/end | Close the room |
POST /v1/sessions/refresh | Refresh 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:
| Call | Purpose |
|---|---|
POST /v1/assessments/{id}/code | Save the editor contents |
POST /v1/assessments/{id}/whiteboard | Save 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.