Skip to main content

Frontend integration

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

Expert Hire developer workflow for Frontend integration
Build against the same evidence trail the product uses.
Hosted linkEmbedded
EffortLowestLow
Candidate stays in your productNoYes
You control the lookNoPartly
Captions, timer, proctoringIncludedIncluded

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.

EventFires when
readyThe room has loaded and resolved its session
joinedThe candidate started the interview
agent.connectedThe AI interviewer is in the room
session.refreshedThe session token rolled over
session.expiringA refresh failed; the room will end soon
interview.endedThe room closed, with a reason
errorAnything 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.