Interviews
The lifecycle, from creation to a scored report.
Lifecycle
create ──> Scheduled ──> In Progress ──> Processing ──> Completed
│ │
└──────────> Cancelled report
| Status | Meaning |
|---|---|
Scheduled | Created, nobody has joined |
In Progress | Candidate is in the room now |
Processing | Interview ended, scoring is running |
Completed | Report is ready |
Cancelled | Cancelled, 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, nospeech_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.