Skip to main content

Interviews

The lifecycle, from creation to a scored report.

Lifecycle

create ──> Scheduled ──> In Progress ──> Processing ──> Completed
                │                                          │
                └──────────> Cancelled                   report
StatusMeaning
ScheduledCreated, nobody has joined
In ProgressCandidate is in the room now
ProcessingInterview ended, scoring is running
CompletedReport is ready
CancelledCancelled, by you or by us

Creating

You can pass an existing candidate_id, or create the candidate inline:

{
  "candidate": { "email": "[email protected]", "name": "Ada Lovelace" },
  "round_type": "general_interview",
  "difficulty": 3,
  "role": "Backend Engineer"
}

difficulty runs 1 to 5. role drives the questions, so make it the real job title rather than an internal code.

An email that already belongs to an Expert Hire account is rejected with 400. That is deliberate: adopting it would attach your interview to somebody's personal prep history.

Questions are generated after the call returns

The create responds 201 straight away and builds the question set in the background, because generating one involves a web search and a model call.

If the candidate joins before that finishes, the interview opens on a generic fallback for the round instead of a real question. For a coding round that fallback is the bare sentence "Write a function that solves the following problem", with no problem statement behind it, and main_question on the report shows the same. The interview still runs; it just never poses the problem you were expecting.

The set is cached per organisation, environment, difficulty, round type and role for 24 hours, so this only bites the first interview of a given shape. If you join early, the agent notices and rebuilds while it is still greeting, so a small gap usually corrects itself.

To remove the timing question entirely, send your own questions with POST /v1/interviews/{id}/questions before the candidate joins. Nothing is generated over a set you supplied. See Supplying your own questions.

Supplying your own questions

curl -X POST $BASE_URL/v1/interviews/$INTERVIEW_ID/questions \
  -H "Authorization: Bearer $EH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"questions": ["Design a rate limiter for a shared API.", "How would you test it?"]}'

Still a placeholder: $BASE_URL, $INTERVIEW_ID, $EH_KEY. Add them under Your values above.

The first entry becomes the opening question and the interviewer works through the rest in the order you sent them. It asks these and does not invent its own alongside them, which is the difference between a supplied set and a generated one: a generated set is a pool it samples from while writing its own questions around it.

Supplying a set also skips generation, so the interview is ready the moment it is created.

Read back what the interviewer will actually be given:

curl $BASE_URL/v1/interviews/$INTERVIEW_ID/questions \
  -H "Authorization: Bearer $EH_KEY"

Still a placeholder: $BASE_URL, $INTERVIEW_ID, $EH_KEY. Add them under Your values above.

supplied: true is your own set. supplied: false with questions present is a generated one. pending: true means generation has not finished, and an interview joined in that state opens on the round's fallback question.

This has to happen before the interview starts. The agent reads the set once, on join, so a later write would be accepted and never used: we return interview_started rather than pretend it landed. Communication rounds do not accept supplied questions.

Candidates

Persist the candidate_id you get back. If you lose it, look the person up by email rather than creating a duplicate:

curl "https://prep-api.experthire.cloud/v1/[email protected]" \
  -H "Authorization: Bearer $EH_SECRET_KEY"

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

GET /v1/candidates also pages the whole list, newest first. PATCH corrects a typo in a name or email.

Deleting is erasure, and it is not reversible:

curl -X DELETE https://prep-api.experthire.cloud/v1/candidates/$CANDIDATE_ID \
  -H "Authorization: Bearer $EH_SECRET_KEY"

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

The person is de-identified rather than dropped. Name, email, phone and every profile field are overwritten; interviews and reports keep their candidate id, so your own counts and averages still reconcile, but the record can no longer be tied to a person.

PATCH and DELETE only work on a candidate you created through the API. One who has since signed in to Expert Hire owns their own profile and deletion, and returns candidate_not_editable. Rewriting their email would otherwise hand someone else their sign-in.

Listing

curl -G https://prep-api.experthire.cloud/v1/interviews \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  --data-urlencode "limit=50"

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

{
  "object": "list",
  "data": [{ "object": "interview", "id": "…", "status": "Completed" }],
  "has_more": true,
  "next_cursor": "b9ec0ced-…"
}

Newest first. Pass status to filter. Page by setting starting_after to next_cursor, which is the last id on the page you just read:

curl -G https://prep-api.experthire.cloud/v1/interviews \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  --data-urlencode "starting_after=$NEXT_CURSOR"

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

The cursor is a position in the list, not a page number. That matters if you poll: with an offset, an interview created between two requests shifts everything down a row and the next page silently repeats or skips one. Paging from the last id you saw cannot.

Listing is not how you find out an interview finished. Poll it and you will either hammer us or miss the transition. Register a webhook and listen for interview.completed, then fetch the report.

Intake: a voice form, not an interview

round_type: "intake" is for the case where the person speaking is your customer, not a candidate. They describe something they want, your questions are the fields you need filled, and you read the answers off the transcript.

It behaves differently from every other round type:

  • It asks your supplied questions in order and nothing else. It never probes, chases specifics or improvises. Supply a set: there is nothing to generate for an intake, and without one it opens on a generic fallback.
  • Nothing is scored. The report carries the transcript and no assessment: no field_knowledge, no speech_analysis, no verdict. The speaker is not being evaluated and is never told they are.
  • There is no introduction phase. It opens on your first question.
{
  "candidate": { "email": "[email protected]", "name": "Dana Reyes" },
  "round_type": "intake",
  "role": "Job requisition intake"
}

Then send the questions before the link is opened, exactly as above. role still keys the question cache, so give each intake shape its own stable value.

The person on an intake call is your customer. Nothing in the room tells them they are being assessed, because they are not.

Scheduling into capacity

Interviews run against a shared pool, so a slot can fill. Ask before you commit a time rather than discovering it when the candidate tries to join:

curl -G https://prep-api.experthire.cloud/v1/interviews/slots \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  --data-urlencode "from=$(date +%s)"

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

{ "object": "list", "data": [{ "start_time": 1786351800, "available": 500 }] }

One entry per half hour. from and to are unix seconds, the default window is the next 24 hours, and the maximum is 14 days. Pass candidate_id and slots that candidate already holds are left out.

Attaching a resume

Resumes make the interview specific rather than generic. Upload, register and score it first, then pass resume_id when you create the interview. The interviewer reads the parsed resume and asks about what is in it.

The resume has to belong to the same candidate. One that belongs to another candidate is rejected with 404 resume_not_found, so a mismatched id fails rather than quietly interviewing against somebody else's history.

Uploading a file is not enough on its own: the resume has to be registered before anything can use it. The full flow is in resumes.

Getting the candidate in

Three ways, covered in frontend integration:

  • Hosted link. We host the room, you email a URL. Least work.
  • Embedded. The room runs inside your page. No redirect for the candidate.
  • Headless. You build the room. Most control, and you give up the most.

The billable moment

Five credits are reserved when you create an interview and committed when the candidate joins. Joining is what dispatches the AI interviewer and starts recording, so it is the point where the interview genuinely costs something.

Cancelling an interview nobody joined returns them:

curl -X POST https://prep-api.experthire.cloud/v1/interviews/$ID/cancel \
  -H "Authorization: Bearer $EH_SECRET_KEY"

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

Cancelling one that already started does not, with one exception: if the room was opened but our interviewer was never reached, cancelling still refunds. A candidate who blocks the camera and drops out never had an interview, so it should not cost one.

Round types

round_type selects the interview style. The catalogue is per-org and some are module-gated, so read yours rather than guessing:

curl https://prep-api.experthire.cloud/v1/round-types \
  -H "Authorization: Bearer $EH_SECRET_KEY"

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

Each entry carries supports_code_editor and supports_whiteboard. GET /v1/companies, /v1/jobs and /v1/coding-languages are the other lookups worth reading at boot.

Commonly available: general_interview, coding, hr_round, system_design, communication, problem_solving_or_aptitude, vc_pitch.

There is no technical round, and no general (it is general_interview). daily_english is not bookable over the API: it is the prep student app's own daily drill, so GET /v1/usage may report the module while the round type is refused. An unknown slug is rejected with 400 invalid_round_type, and the error message lists the slugs your organisation has, so you never have to guess.

Whether a round offers a code editor or whiteboard comes back on the join grant as supports_code_editor and supports_whiteboard, so you do not have to hardcode a mapping that will drift.

Code in an interview round

When the join grant reports supports_code_editor, the hosted room renders an editor and saves what the candidate writes with POST /v1/interviews/{id}/code. Send language alongside it (python, c_cpp, golang) so the report can label the block; it comes back as code_language.

Last write wins. Save periodically rather than only on submit, because a partial answer scores better than nothing if the candidate runs out of time. There is no revision history and no per-submission timestamp: the report carries the final state only.

An interview round stores code. It does not run it. feedback.code_score is a model reading the submission against the question that was asked, and it first checks the code is a genuine attempt at that question. An unrelated snippet scores 0 with "No code available for evaluation" even though the code was stored and comes back in code. For graded execution against test cases, book a coding test instead.