Skip to main content

Psychometric Test

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

Expert Hire developer workflow for Psychometric Test
Build against the same evidence trail the product uses.

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
StatusMeaning
assignedCreated, the candidate has not opened it
startedThe candidate opened it, the response clock is running
in_progressAt least one autosave has landed
scoringSubmitted. The scores are durable, the written profile is queued
completedThe written profile landed, or the sweeper finalized the session without one
timed_outLeft unopened for 7 days and swept
cancelledCancelled 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:

FieldAt scoringAt completed
traits, interests, value_axes, role_fitsYesYes
headline, holland_code, profile_type, archetypeYesYes
directions, company_fit, dimension_scoresYesYes
summary, environment_fitEmpty stringWritten
strengths, work_style, role_suggestions, explore_roles, company_typesnullWritten
completed_atAbsentSet

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.

EventFires whenStatus in the payload
assessment.assignedA new session is assigned (not on a resumed assign)assigned
psychometric.completedThe submit makes the deterministic scores durablescoring
psychometric.report_readyThe written profile lands, or is given up oncompleted
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/sessions only 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_ready as "not submitted".
  • The list filters on status only. There is no candidate_id filter, so finding a specific candidate's sessions means paging. Store the session id at assign time.
  • Idempotency-Key is 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. position is 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.