Frontend integration
Two ways to get a candidate into the room, and how to choose.

| Hosted link | Embedded | |
|---|---|---|
| Effort | Lowest | Low |
| Candidate stays in your product | No | Yes |
| You control the look | No | Partly |
| Captions, timer, proctoring | Included | Included |
Start hosted. Move to embedded if the redirect out of your product matters. Driving the room yourself is not an option; see Headless.
Hosted link
Mint a link and send it. This is the whole integration.
curl -X POST https://prep-api.experthire.cloud/v1/interviews/$ID/launch-link \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"ttl_seconds": 604800}'
Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.
{
"object": "launch_link",
"url": "https://room.experthire.io/launch?token=ehp_lt_…",
"expires_at": 1789000000
}
The link works once. The candidate lands on a page that asks them to click before anything is spent, then runs the interview and closes itself.
The token is exchanged on click, not on page load. Mail scanners and link-preview fetchers follow URLs; a load-time exchange would burn the token before the candidate ever saw the page. If you build your own landing page, do the same.
Embedded
Your server mints a session, your page mounts the room.
npm install @experthireai/room
<div id="eh-room" style="height: 640px"></div>
<script type="module">
import { ExpertHire } from '@experthireai/room'
const eh = await ExpertHire.load({ publishableKey: 'ehp_pk_live_…' })
const room = await eh.mount({
container: '#eh-room',
sessionToken, // from your server
interviewId,
})
room.on('interview.ended', ({ reason }) => {
// Scores are not ready yet. Wait for the webhook.
showThanks(reason)
})
room.on('error', ({ code, message }) => report(code, message))
</script>
Two keys, two jobs. Your secret key (ehp_sk_…) stays on your server and
mints the session. Your publishable key (ehp_pk_…) is safe in page source
and only resolves which origins may frame the room.
Your origin must be allowlisted first, and it must match exactly, including scheme and port.
Events
mount() resolves once the room is running and rejects if it never starts, so
a try/catch catches the failures you will actually hit: blocked by CSP, wrong
origin, key not allowlisted.
| Event | Fires when |
|---|---|
ready | The room has loaded and resolved its session |
joined | The candidate started the interview |
agent.connected | The AI interviewer is in the room |
session.refreshed | The session token rolled over |
session.expiring | A refresh failed; the room will end soon |
interview.ended | The room closed, with a reason |
error | Anything unrecoverable |
Live transcript is deliberately not forwarded to your page. Candidate speech leaving our surface changes who is a controller of that data under most data protection agreements, so it is a future opt-in scope rather than a default.
Permissions
This is the most common integration failure, and it fails silently: the candidate sees a black tile and no error.
allow= on an iframe can only delegate what the parent page already has. If
your own page sends a Permissions-Policy header, it must name us:
Permissions-Policy: camera=(self "https://room.experthire.io"),
microphone=(self "https://room.experthire.io")
If you send no Permissions-Policy at all, delegation works and there is
nothing to do.
Your CSP must also allow framing us and loading the loader:
frame-src https://room.experthire.io;
script-src https://room.experthire.io;
We do not send X-Frame-Options on the room, deliberately: it has no allowlist
form, so a host that must be embeddable by named partners cannot send it. The
control is frame-ancestors, which we set per request from your allowlist. An
unrecognised key gets frame-ancestors 'none', not a permissive default.
Headless, and why it is not offered
Driving Agora yourself from the realtime credentials on join looks attractive
and does not work from a browser. /v1 does not send
Access-Control-Allow-Origin for partner origins, so the preflight for
POST /v1/interviews/{id}/join from your page is refused and the call never
leaves the browser. That is deliberate: the API authenticates on a credential,
never on an origin, and opening it to arbitrary origins would widen the whole
prep backend rather than just /v1.
Even with the transport solved, a headless interview is scored on audio and
transcript alone. You lose live captions, the proctoring event trail, the code
editor and whiteboard, server-driven timer sync and pause-on-leave, and
agent-crash recovery. The report comes back with an empty events[], because
nothing was recorded unless you sent it, and a recruiter reads that as a clean
session.
Use a hosted link or the embedded room. If you have a case neither covers, talk to us before building.
Sandbox
Sandbox interviews run the same code and the same scoring model as live ones, so the report shape and quality are real. Three things differ, all cost bounds:
- Nothing is recorded.
- Duration is capped at five minutes regardless of round type.
- Interviews and their data are deleted after 30 days.
Sandbox has its own small monthly quota and never touches your live credit pool. Candidates are never emailed in sandbox.