Evaluation Service API
The Evaluation Service drives the persona questionnaire flow and runs the matching algorithm once all questions are answered. It runs on internal port 9103 and is exposed through the gateway at the /evaluation prefix.
Base paths:
| Environment | URL |
|---|---|
| Production | https://api.zeswa.com/evaluation |
| Local | http://localhost:9103 |
All endpoints require a valid Bearer token issued by the Profile Service (POST /persona/login).
Overview
The evaluation lifecycle is:
- Admin creates (or resumes) an evaluation —
POST /evaluationwith apersonaIdandteamId. If an active evaluation already exists for that persona + team, it is returned instead of creating a new one (resumed: true). Otherwise a new one is created. - User progresses —
GET /evaluation/:idreturns the next unanswered question. The user submits each answer viaPOST /evaluation/:id/answer, which returns the next question. Clients that need a structural view of the entire questionnaire (e.g. a sidebar / progress rail) can callGET /evaluation/:id/questionsfor the full ordered list with per-question status. - Resume after abort — if the user abandons the session, answers are preserved. Calling
POST /evaluationagain returns the same evaluation withresumed: trueand the next unanswered question. - Completion — once all questions are answered, the algorithm runs automatically and the results are stored. Any subsequent
GET /evaluation/:idreturns the full match results.
GET /evaluation
Lists evaluations for a persona, optionally filtered by team or status. Use this to discover an active evaluation without knowing its ID.
Authentication: Required.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
personaId |
string | ✅ | UUID of the persona |
teamId |
integer | — | Filter by team ID |
status |
string | — | Filter by status: pending, in_progress, or complete |
Success response — 200 OK
{
"items": [
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "in_progress",
"progress": { "answered": 42, "total": 158 },
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z"
}
],
"total": 1
}
Error responses
| Status | Condition |
|---|---|
400 |
personaId query param missing |
401 |
Missing Authorization header |
403 |
Invalid or expired token |
500 |
Internal server error |
POST /evaluation
Creates a new evaluation session, or resumes an existing active one.
If a pending or in_progress evaluation already exists for the same personaId + teamId, it is returned with resumed: true and currentQuestion pointing to the next unanswered question. No duplicate evaluation is created.
Authentication: Required (admin token).
Request body
{
"personaId": "3f4e2d1c-...",
"teamId": 1
}
| Field | Type | Required | Description |
|---|---|---|---|
personaId |
string | ✅ | The ID of the persona being evaluated |
teamId |
integer | ✅ | The numeric ID of the algorithm team to match against |
Success response — 201 Created (new evaluation)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "pending",
"resumed": false,
"progress": {
"answered": 0,
"total": 158
},
"currentQuestion": {
"id": 1,
"text": "I enjoy working in a structured environment",
"type": "LIKERT",
"likertAnswerSet": "agree",
"questionGroupId": 1,
"questionGroupName": "Work Style",
"questionGroupTitle": "Your Work Style"
},
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
Success response — 200 OK (resumed evaluation)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "in_progress",
"resumed": true,
"progress": {
"answered": 6,
"total": 158
},
"currentQuestion": {
"id": 7,
"text": "I rarely work outside of a 9 to 5 workday",
"type": "LIKERT",
"likertAnswerSet": "agree",
"questionGroupId": 4,
"questionGroupName": "workexpectation",
"questionGroupTitle": "Work Expectation"
},
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:45:00.000Z"
}
Error responses
| Status | Condition |
|---|---|
401 |
Missing Authorization header |
403 |
Invalid or expired token |
404 |
teamId does not exist in the database |
400 |
Validation failed (missing/invalid fields) |
500 |
Internal server error |
GET /evaluation/:id
Returns the current state of an evaluation. If the evaluation is incomplete, the response includes the next unanswered question. If the evaluation is complete, the response includes the algorithm match results.
Authentication: Required.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Evaluation UUID |
Success response — 200 OK (incomplete)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "in_progress",
"progress": {
"answered": 12,
"total": 158
},
"currentQuestion": {
"id": 15,
"text": "I prefer to resolve conflicts through compromise",
"type": "LIKERT",
"likertAnswerSet": "agree",
"questionGroupId": 3,
"questionGroupName": "Conflict Resolution",
"questionGroupTitle": "How You Handle Conflict"
},
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:35:00.000Z"
}
Success response — 200 OK (complete)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "complete",
"progress": {
"answered": 158,
"total": 158
},
"results": {
"overallScore": 72.5,
"overallCategory": "high",
"totalTimeTaken": 840,
"dimensions": [
{
"questionGroupId": 1,
"name": "Work Style",
"scoreType": "SUPPLEMENTARY",
"scorePercentage": 78.3,
"scoreCategory": "high",
"weightPercentage": 15
}
]
},
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z"
}
Score categories
| Category | Score range | Meaning |
|---|---|---|
high |
≥ 66% | Strong match |
medium |
33% – 65% | Moderate match |
low |
< 33% | Weak match |
Error responses
| Status | Condition |
|---|---|
401 |
Missing Authorization header |
403 |
Invalid or expired token |
404 |
Evaluation not found |
500 |
Internal server error |
POST /evaluation/:id/answer
Submits an answer for a question. Returns the next unanswered question, or the final results if all questions have been answered.
Authentication: Required.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Evaluation UUID |
Request body
{
"questionId": 15,
"value": 4,
"timeTaken": 12
}
| Field | Type | Required | Description |
|---|---|---|---|
questionId |
integer | ✅ | The ID of the question being answered |
value |
integer (1–5) | ✅ | The Likert scale answer (1 = strongly disagree, 5 = strongly agree) |
timeTaken |
integer (≥ 0) | ❌ | Seconds the user spent on this question. Defaults to 0 if omitted. |
Success response — 200 OK (more questions remain)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "in_progress",
"progress": {
"answered": 13,
"total": 158
},
"nextQuestion": {
"id": 16,
"text": "I adapt my approach when a strategy is not working",
"summary": "Assesses behavioural flexibility — whether the candidate adjusts tactics when current approaches fail.",
"type": "LIKERT",
"likertAnswerSet": "usually",
"questionGroupId": 3,
"questionGroupName": "Conflict Resolution",
"questionGroupTitle": "How You Handle Conflict"
}
}
Success response — 200 OK (evaluation complete)
{
"id": "a1b2c3d4-...",
"personaId": "3f4e2d1c-...",
"teamId": 1,
"status": "complete",
"progress": {
"answered": 158,
"total": 158
},
"nextQuestion": null,
"results": {
"overallScore": 72.5,
"overallCategory": "high",
"totalTimeTaken": 840,
"dimensions": [ ... ]
}
}
Error responses
| Status | Condition |
|---|---|
400 |
value out of range (1–5) or questionId not in this evaluation |
401 |
Missing Authorization header |
403 |
Invalid or expired token |
404 |
Evaluation not found |
409 |
Evaluation is already complete |
500 |
Internal server error |
GET /evaluation/:id/questions
Returns the full ordered list of questions for an evaluation, each annotated with its status (answered, current, or pending) and — when answered — the stored answer. Designed for HUB to render a sidebar / progress rail.
Server-side ordering matches the order questions are served by GET /evaluation/:id and POST /evaluation/:id/answer. Exactly one item has status: "current" while the evaluation is active; zero items have status: "current" once the evaluation is complete.
Always returned with Cache-Control: no-store — the response mutates with each answer.
Authentication: Required.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Evaluation UUID |
Success response — 200 OK
{
"items": [
{
"id": 12,
"text": "I prefer to plan ahead.",
"summary": "Planning preference",
"type": "LIKERT",
"likertAnswerSet": "agree",
"questionGroupId": 3,
"questionGroupName": "Conscientiousness",
"questionGroupTitle": "How you organise work",
"order": 0,
"status": "answered",
"answer": {
"value": 4,
"timeTaken": 12,
"answeredAt": "2026-05-10T13:42:11.823Z"
}
},
{
"id": 13,
"text": "I adapt my approach when a strategy is not working.",
"type": "LIKERT",
"likertAnswerSet": "usually",
"questionGroupId": 3,
"questionGroupName": "Conscientiousness",
"questionGroupTitle": "How you organise work",
"order": 1,
"status": "current"
},
{
"id": 14,
"text": "I keep my workspace organised.",
"type": "LIKERT",
"likertAnswerSet": "agree",
"questionGroupId": 3,
"questionGroupName": "Conscientiousness",
"questionGroupTitle": "How you organise work",
"order": 2,
"status": "pending"
}
],
"progress": {
"answered": 1,
"total": 80
}
}
Field reference
| Field | Type | Description |
|---|---|---|
items[] |
array | Questions in delivery order. Length equals progress.total under normal operation. |
items[].id |
integer | Numeric question ID. |
items[].text |
string | Question text shown to the user. |
items[].summary |
string | null | Optional short summary of what the question measures. |
items[].type |
string | Question type, e.g. LIKERT. |
items[].likertAnswerSet |
string | null | Likert answer set key when type === "LIKERT". |
items[].questionGroupId |
integer | Numeric ID of the dimension/group this question belongs to. |
items[].questionGroupName |
string | Internal group name. |
items[].questionGroupTitle |
string | User-facing group title. |
items[].order |
integer | 0-based position in the evaluation’s ordered list. Strictly increasing. |
items[].status |
enum | answered | current | pending. See semantics below. |
items[].answer |
object | absent | Present only when status === "answered". Contains value (1–5), timeTaken, answeredAt. |
progress.answered |
integer | Number of answered questions. |
progress.total |
integer | Total questions in the evaluation. |
Status semantics
| Status | Meaning | answer field |
|---|---|---|
answered |
User has submitted an answer for this question. | Present |
current |
The next unanswered question — same id as currentQuestion.id from GET /:id. Exactly 0 or 1 items carry this status (1 while active, 0 once complete). |
Absent |
pending |
Not yet answered. Always comes after the current item in order. |
Absent |
Under normal flow the rail reads contiguously: answered…* current pending…*.
Privacy note
The answer.value field is included so HUB can offer a “review my answers” experience if desired. The progress sidebar itself only needs status. If your UI deliberately avoids spoiling past answers, simply do not render item.answer.value.
Error responses
| Status | Condition |
|---|---|
400 |
Invalid id |
401 |
Missing Authorization header |
403 |
Invalid or expired token |
404 |
Evaluation not found |
500 |
Internal server error |
Evaluation Status States
| Status | Description |
|---|---|
pending |
Created, no answers yet |
in_progress |
At least one answer submitted |
complete |
All questions answered, results available |
Testing with the API Runner
A script is provided to simulate the full flow end-to-end against the running service:
# Start the evaluation service first
npm run evaluation
# In another terminal, run the simulation
npm run evaluation:runner
# Override defaults
PERSONA_EMAIL=admin@example.com \
TEAM_ID=1 \
DEFAULT_ANSWER=3 \
npm run evaluation:runner
The runner will:
- Obtain an access token via
POST /persona/login - Resolve the
personaIdviaGET /persona/me - Create an evaluation
- Loop through all questions, submitting value
3(neutral) for each - Print a formatted results table
POST /evaluation/:id/team-match
Computes a combined Role + Workstyle score for a candidate’s evaluation against a team. On-demand — nothing is persisted.
Authentication: Required.
Request body (all optional)
| Field | Type | Default | Description |
|---|---|---|---|
roleId |
number | First role linked to the evaluation’s team | Override the role used for role match. |
algorithmUserId |
number | Bridged from the evaluation’s persona | Numeric algorithm user id used to look up UserExperience and UserGoal records. |
roleWeight |
number (0…100) | 50 |
Blend weight for role match vs workstyle. |
Sample response
{
"evaluationId": "abc-123",
"algorithmTeamId": 1,
"roleId": 1,
"algorithmUserId": 4,
"workstyle": { "score": 72.5, "scoreCategory": "high", "dimensions": [/* 16 */] },
"roleMatch": {
"roleId": 1, "score": 64.2, "scoreCategory": "medium",
"experienceScore": 66.0, "goalScore": 30.0,
"breakdown": [{ "name": "industry", "priority": 1, "percentageMatch": 0.5, "points": 6.5, "maxPoints": 13, "source": "experience" }]
},
"combined": { "score": 68.3, "scoreCategory": "high", "roleWeight": 50 }
}
If no Role / RoleSpecification is configured for the team, roleMatch is null and combined.score === workstyle.score.
POST /evaluation/:id/compare-team
Re-scores an existing evaluation against a different team than the one it was created for. The candidate’s stored answers are reused as-is; only the team context (members + their answers) changes. Read-only and on-demand — nothing is persisted.
Use case: Candidate completed an evaluation in Team A; admin wants to see how compatible they would be with Team B without re-collecting answers or creating a new evaluation.
Authentication: Required.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Source evaluation UUID (the one created against Team A). |
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
algorithmTeamId |
integer (≥ 1) | ✓ | — | Numeric id of the target team to compare against. Must differ from the evaluation’s own algorithmTeamId. |
roleId |
integer (≥ 1) | — | — | Optional role for an additional role match score (typically a role on the target team). |
algorithmUserId |
integer (≥ 0) | — | Bridged from persona | Numeric algorithm user id used for role match UserExperience/UserGoal lookups. |
roleWeight |
number (0…100) | — | 50 |
Blend weight for combined Role + Workstyle. Only used if a role match is computed. |
Sample response — 200 OK
{
"evaluationId": "abc-123",
"personaId": "3f4e2d1c-...",
"sourceTeam": { "algorithmTeamId": 1, "teamName": "Engineering Core" },
"targetTeam": { "algorithmTeamId": 2, "teamName": "Growth Squad" },
"workstyle": {
"overallScore": 67.8,
"overallCategory": "high",
"dimensions": [
{ "questionGroupId": 1, "name": "values", "scoreType": "TOP_TEN",
"scorePercentage": 72.4, "scoreCategory": "high", "weightPercentage": 100 }
]
},
"roleMatch": null,
"combined": null,
"answersUsed": 158,
"computedAt": "2026-05-10T15:14:32.765Z"
}
When roleId is supplied and a RoleSpecification resolves, roleMatch and combined are populated:
{
"roleMatch": {
"roleId": 4, "score": 65.0, "scoreCategory": "medium",
"experienceScore": 70, "goalScore": 60,
"breakdown": [/* … */]
},
"combined": { "score": 66.4, "scoreCategory": "medium", "roleWeight": 50 }
}
If a roleId was provided but the role match couldn’t be computed, roleMatch is returned with an error field instead of a score, and combined remains null.
Headers
Cache-Control: no-store— per-call, deterministic but not cacheable.
Error responses
| Status | Condition |
|---|---|
400 |
algorithmTeamId missing/non-positive |
400 |
Evaluation has no answers yet (answers.length === 0) |
400 |
algorithmTeamId equals the evaluation’s own team — use GET /:id (own results) or POST /:id/team-match instead |
401 |
Missing Authorization header |
403 |
Invalid or expired token |
404 |
Evaluation not found |
404 |
Target team algorithmTeamId not found |
500 |
Internal server error |
See the Compare Team — Admin Portal Integration Guide for the recommended UX and TypeScript fetcher.
POST /evaluation/compare
Computes pairwise compatibility between two completed evaluations.
Authentication: Required.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
evaluationIdA |
string | ✓ | First evaluation UUID. |
evaluationIdB |
string | ✓ | Second evaluation UUID (must differ from A). |
mode |
enum | — | PAIRWISE (default) / MUTUAL / SIMULATED_TEAM |
Modes
- PAIRWISE — A is the team-of-one; B is the candidate. Asymmetric.
- MUTUAL — Run PAIRWISE in both directions and average. Symmetric. Returns
reverseScoreandreverseDimensionsso the UI can show both raw scores. - SIMULATED_TEAM — same calculation as MUTUAL; conceptually framed as 2-person team cohesion.
Sample response
{
"mode": "MUTUAL",
"evaluationIdA": "abc-123",
"evaluationIdB": "def-456",
"score": 71.4,
"scoreCategory": "high",
"reverseScore": 68.9,
"dimensions": [/* … */],
"reverseDimensions": [/* … */]
}
See Algorithm: Role Match & Combined Scoring for the full math, and the Admin and HUB UI integration guides for screen-by-screen wiring.