Psychometric Test
A self-report career profile. Deterministic scores, with prose written on top of them.

Psychometric Test is a 40 item questionnaire that reports how someone works and what kind of work pulls them. It scores three groups of bipolar dimensions: personality (openness, conscientiousness, extraversion, agreeableness, emotional stability), interests (the six RIASEC pulls), and three work preference axes (structure, direction, focus). From those it derives a Holland code, a headline, and a ranked fit against a fixed catalog of 12 anchor roles.
Every number in the report is computed in Go from the answers. The language
model writes the prose only, and a Go validator rejects any draft that puts a
digit in summary or environment_fit, compares the candidate to other people
anywhere, or names a role in role_suggestions outside the ranked set. If the
model never produces an acceptable draft, the scores still ship.
Use it for guidance, not for screening. It is a self-report questionnaire
with no right answers, so it measures what someone says about themselves on one
day. The report carries disclaimer and roles_disclaimer for exactly this
reason, and both are meant to be displayed. If you need a signal you can rank
candidates on, use interviews or a coding test.
Lifecycle
assign ──> assigned ──> started ──> in_progress ──> scoring ──> completed
│ │
├──> cancelled report exists
└──> timed_out
| Status | Meaning |
|---|---|
assigned | Created, the candidate has not opened it |
started | The candidate opened it, the response clock is running |
in_progress | At least one autosave has landed |
scoring | Submitted. The scores are durable, the written profile is queued |
completed | The written profile landed, or the sweeper finalized the session without one |
timed_out | Left unopened for 7 days and swept |
cancelled | Cancelled while still assigned |
1. Pick an instrument
curl https://prep-api.experthire.cloud/v1/psychometric-tests \
-H "Authorization: Bearer $EH_SECRET_KEY"
Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.
{
"object": "list",
"data": [
{
"object": "psychometric_test",
"id": "11111111-1111-1111-1111-111111111111",
"title": "Psychometric Test",
"slug": "career-dna",
"estimated_minutes": 7,
"created_at": 1786348200
}
],
"has_more": false,
"total_count": 1
}
You get the shared Expert Hire catalog plus anything authored for your
organization. In practice that is one instrument. estimated_minutes is a
display hint, not a limit.
The items themselves are never served here. One fixed validated instrument that a partner could read is one a partner could coach against, and there is no second form to fall back on. The candidate gets the items on their own session token and nobody else does.
2. Assign it
curl -X POST https://prep-api.experthire.cloud/v1/psychometric-sessions \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d "{\"candidate\":{\"email\":\"[email protected]\",\"name\":\"Ada Lovelace\"},\"test_id\":\"$TEST_ID\"}"
Still a placeholder: $EH_SECRET_KEY, $TEST_ID. Add them under Your values above.
201:
{
"object": "psychometric_session",
"id": "8c41f0d2-5b6e-4a91-9f3c-2ad7e4b81c60",
"status": "assigned",
"candidate_id": "7f1c2b90-3a44-4d51-9c0e-1b2f9c4d1a55",
"test_id": "11111111-1111-1111-1111-111111111111",
"test_title": "Psychometric Test",
"report_available": false,
"narrative_ready": false,
"resumed": false,
"environment": "live",
"livemode": true,
"created_at": 1786348200
}
Pass an existing candidate_id, or create the candidate inline as above. The
inline form is the same upsert as POST /v1/candidates, so an address you have
used before returns the same candidate.
The assign is idempotent by organization, candidate and instrument. If that
candidate already holds a session that is assigned, started or
in_progress, you get 200 with resumed: true and nothing is charged.
Retrying a create you are not sure landed is safe.
Idempotency stops at the live statuses. A candidate who already completed
the instrument, or whose session was cancelled or expired, gets a second session
and a second credit. Idempotency-Key is optional rather than required, so guard
re-assignment on your side if you keep a candidate record around.
3. Send the candidate a link
curl -X POST https://prep-api.experthire.cloud/v1/psychometric-sessions/$SESSION_ID/launch-link \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"ttl_seconds": 604800, "redirect_url": "https://app.example.com/psychometric/done"}'
Still a placeholder: $SESSION_ID, $EH_SECRET_KEY. Add them under Your values above.
201:
{
"object": "launch_link",
"subject_kind": "psychometric",
"subject_id": "8c41f0d2-5b6e-4a91-9f3c-2ad7e4b81c60",
"url": "https://room.experthire.io/launch?token=ehp_lt_...",
"token": "ehp_lt_...",
"expires_at": 1786953000
}
The body is optional. ttl_seconds runs 300 to 604800 and defaults to 7 days.
Read token and url from their own fields rather than parsing or rebuilding
the URL. The host is a property of the deployment, not of your key's
environment: https://room.experthire.io on production and
https://room.sandbox.experthire.io elsewhere, and a deployment can override
it.
An unopened assignment is swept 7 days after it was created, and expires_at is
capped to that deadline. Ask for a week on day six and you get a day. Mint a
link after the deadline and the call is refused with assignment_expired
instead of handing you a URL that opens nothing.
The link is single use, and the token is stored hashed, so the response above is the only time you see it. If you land the candidate on your own page first, exchange the token on a click and never on page load: mail scanners follow URLs and will burn it before the candidate arrives.
What the candidate does
The hosted room presents the questionnaire as a short conversation: a character says a line, the candidate picks a reply. Behind that it is three calls on the session token minted by the launch exchange.
POST /v1/psychometric-sessions/{id}/start returns the instrument and starts the
response clock. POST .../progress autosaves. POST .../submit scores it.
start is not optional. Progress and submit both refuse a session that is still
assigned, with session_not_started.
Answers are a flat map keyed by question id:
{
"answers": {
"22222222-2222-2222-2222-000000000001": 4,
"22222222-2222-2222-2222-000000000034": "tidy"
}
}
A likert item takes an integer 1 to 5. A choice item takes an option key.
Both endpoints replace the stored map rather than merging into it, and the submit is scored from the body you send, not from what was autosaved. Always send every answer you have collected, not the page the candidate just finished.
If you render the questionnaire yourself, two things will catch you out. A
likert value sent as the string "4" rather than the number 4 is discarded
silently, and so is a choice key that does not match an option exactly. Neither
raises an error; the item simply contributes nothing and that dimension ends up
with less evidence behind it.
The other one is where the wording lives. Likert items carry no options at
all. Their five worded answers are in replies, in scale order, each with the
value to send:
{
"id": "22222222-2222-2222-2222-000000000001",
"question_type": "likert",
"text": "I enjoy exploring new ideas and unconventional approaches.",
"npc_line": "Hey {name}! Should we try something new here, or stick with what we usually do?",
"replies": [
{ "v": 1, "text": "Stick with what we usually do." },
{ "v": 2, "text": "Lean toward the usual way." },
{ "v": 3, "text": "Could go either way." },
{ "v": 4, "text": "Let's try something new." },
{ "v": 5, "text": "Yes, I love new things!" }
]
}
Choice items carry both: options for the key and label, replies for the
conversational phrasing of the same answers, where v is the option key. Render
replies and you match the hosted room. Render options and likert items come
out blank.
npc_line contains a literal {name} placeholder where the candidate's first
name goes. Substitute it or strip it.
Untimed, and unproctored
There is no timer endpoint and no proctoring events endpoint for Psychometric Test. Neither is an oversight. Nobody can cheat a questionnaire that has no right answers: looking something up, switching tabs or asking a friend does not produce a better result, only a less honest one. Adding a countdown would only push people to answer faster than they can read.
What the instrument does instead is measure how the answers were given, and
report that alongside them. It measures how far apart the answers on one
dimension sit once the reverse worded items are flipped back, how much of the
instrument was answered at all, whether one value was used for most of the
questionnaire, whether every answer leans to one end of the scale regardless of
how the item was framed, and whether the whole thing was finished faster than
the questions can be read. That last one is why start matters: the elapsed
clock runs from that call and not from the assign, so a link mailed on Monday
and opened on Friday is not read as four days of deliberation.
Those quality signals stay with the candidate. They are not in the partner report, along with evidence counts, per-role gaps, skills to build and the raw answers.
The report
curl https://prep-api.experthire.cloud/v1/psychometric-sessions/$SESSION_ID/report \
-H "Authorization: Bearer $EH_SECRET_KEY"
Still a placeholder: $SESSION_ID, $EH_SECRET_KEY. Add them under Your values above.
Before the submit this returns 404 report_not_ready with a message naming the
current status.
{
"object": "psychometric_report",
"session_id": "8c41f0d2-5b6e-4a91-9f3c-2ad7e4b81c60",
"status": "completed",
"scoring_version": 2,
"narrative_version": 2,
"narrative_ready": true,
"holland_code": "IAE",
"profile_type": "inventive_analyst",
"headline": "The inventive analyst",
"traits": [
{
"key": "conscientiousness",
"label": "Conscientiousness",
"low_label": "Spontaneous",
"high_label": "Structured",
"position": 72.5,
"band": "moderate_high",
"band_label": "Structured",
"blurb": "You work to a plan and like knowing what done looks like before you start."
}
],
"role_fits": [
{
"key": "research",
"label": "Research / R&D",
"score": 81.4,
"rank": 1,
"tier": "strong",
"tier_label": "Strong fit"
}
],
"disclaimer": "This reflects how you answered today, and people answer differently on different days. ..."
}
Three things about the shape are worth knowing before you build a renderer.
Traits are bipolar, and unmeasured means null. position runs 0 to 100
between low_label and high_label, and neither end is the good end. A
dimension the answers never touched comes back with position: null and no
band, not a 50. Render that as "not measured". Every trait array is fixed
length, so a null is the only way to tell.
Role fit is a distance, not a ranking. Each anchor role has a target level
on the dimensions it cares about, and the score falls as the candidate sits
further from those targets. Two roles can both score 80, and every role can
score badly. The 12 role catalog exists to make that measurement stable; it is
not the set of jobs the candidate could do. Breadth lives in explore_roles,
which the model writes and which may name anything.
dimension_scores is a legacy mirror and it lies about coverage. It fills
any unmeasured personality or interest dimension with 50. Read traits,
interests and value_axes instead.
Two smaller ones. archetype is the top role key, not a personality type;
profile_type and headline are what come from the Holland letters. And every
json shaped field on the report (directions, explore_roles, company_fit,
strengths, work_style, role_suggestions, company_types) is present as
JSON null rather than absent when it has not been written, so guard before
iterating.
Scores land before the prose
The submit is where the numbers become durable. Everything the language model writes arrives later:
| Field | At scoring | At completed |
|---|---|---|
traits, interests, value_axes, role_fits | Yes | Yes |
headline, holland_code, profile_type, archetype | Yes | Yes |
directions, company_fit, dimension_scores | Yes | Yes |
summary, environment_fit | Empty string | Written |
strengths, work_style, role_suggestions, explore_roles, company_types | null | Written |
completed_at | Absent | Set |
narrative_ready is the flag to branch on. It is narrative_version > 0, and
the negative values are meaningful: -1 means the profile is still being
retried, -2 means we gave up after 24 hours. In both cases summary carries a
placeholder sentence saying so, which you do not want to publish as if the model
had written it.
Webhooks
The result is two moments rather than one, so there are two result events, plus one at assignment.
| Event | Fires when | Status in the payload |
|---|---|---|
assessment.assigned | A new session is assigned (not on a resumed assign) | assigned |
psychometric.completed | The submit makes the deterministic scores durable | scoring |
psychometric.report_ready | The written profile lands, or is given up on | completed |
curl -X POST https://prep-api.experthire.cloud/v1/webhook-endpoints \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourcompany.com/hooks/experthire",
"description": "Psychometric Test consumer",
"enabled_events": ["psychometric.completed", "psychometric.report_ready"]
}'
Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.
The payload is the session, never the profile. The instrument is explicitly not a hiring signal, so the numbers are served from an endpoint a secret key authenticates against rather than pushed to wherever a URL happens to point. Fetch the report when an event arrives.
assessment.assigned is shared with coding and Prompt Engineering assignments, so
check data.object.object for psychometric_session. The two psychometric.*
events belong to this product alone, but they are published from the shared
scoring path, which means they also cover sessions taken inside Expert Hire's
own product by your organization's students. Match the payload id against the
session ids you stored.
Which event you act on depends on what you show. If you render the bands and the
role fits, psychometric.completed is enough and it is the faster one. If you
quote the written profile, wait for psychometric.report_ready and check
narrative_ready on it, because that event also fires for the profile we could
not write. Delivery is at least once; deduplicate on the envelope id. Signing
and retries are in webhooks.
Metering
One credit from the psychometric pool, reserved when the session is created.
Nothing else in the flow charges: the launch link, the questionnaire, the
submit, the scoring, the language model call and every read of the report are
all covered by that one credit.
The reservation happens after the instrument and any resumable session have been
resolved, so a bad test_id and a resumed assign both cost nothing.
Cancelling returns it, while the candidate has not opened the session:
curl -X POST https://prep-api.experthire.cloud/v1/psychometric-sessions/$SESSION_ID/cancel \
-H "Authorization: Bearer $EH_SECRET_KEY"
Still a placeholder: $SESSION_ID, $EH_SECRET_KEY. Add them under Your values above.
The write is conditional on the session still being assigned. One that started
underneath you is refused with session_already_started rather than refunded,
and cancelling an already cancelled session is a 200 that changes nothing and
does not refund twice.
An assignment that expires unopened is not refunded. Expiry is not a decision you made, so the cancel endpoint is what hands the credit back, and it is available for the whole 7 day window. If you assign in bulk, sweep your own unopened sessions before day seven.
Two names differ and it catches everyone once: the credit pool is
credits.psychometric on GET /v1/usage, and the feature gate is
modules.psychometric. With the module off, the secret-key routes return 403
with code module_not_enabled. Two things are not covered by that gate: a
resumed assign returns 200 because it never reaches the entitlement check, and
the candidate routes are authenticated by the session token alone. Read
GET /v1/usage rather than probing with an assign.
Sandbox draws on a separate monthly counter. It is a usage record rather than a balance, so a sandbox session is never refunded, cancelled or not. Sandbox sessions are purged after 30 days.
What is not here yet
Honest gaps, not roadmap.
- No embedded route.
POST /v1/sessionsonly binds a session token to an interview. The launch link and its exchange are the only way to get a credential the candidate endpoints accept, so the iframe path documented in frontend integration does not cover Psychometric Test. - No secret-key read of a single session. Retrieving one session is
session-token only, because the response carries the items. To check one
session's status from your server, list and filter, or call the report and
treat
report_not_readyas "not submitted". - The list filters on status only. There is no
candidate_idfilter, so finding a specific candidate's sessions means paging. Store the session id at assign time. Idempotency-Keyis optional. The assign is also idempotent by candidate and instrument, which covers the retry case, but nothing else on the API is.- No retake or rescore endpoint. Assigning the same instrument to the same candidate again after they finish creates a new session and charges again.
- No cohort norms.
positionis an absolute score against the instrument, not a percentile against your candidates. There is no ranking, and the model is blocked from implying one.
Every operation, with its credential, is listed in the API reference.