This is the full reference for version 1 of the Test Candidates API. If you are starting out, read create and use an API key first.
The same contract is published as an OpenAPI 3.1 file, which you can load into Postman, Insomnia or a code generator: openapi.yaml.
The basics
Base URL
https://www.testcandidates.com/api
The same API also answers at https://api.picked.ai/assessment. The paths after
the base URL are the same on both.
Authentication. Send your key and secret on every request, in either form:
Authorization: Bearer <key>:<secret>
Authorization: Basic <base64 of key:secret>
The examples below use Bearer with the key and secret in the environment
variables TC_KEY and TC_SECRET. Keep the secret on your server: never put
it in a web page or a mobile app.
Format. Requests and responses are JSON. A single item comes back as
{ "data": { ... } } and a list as { "data": [ ... ], "meta": { ... } }.
Times are ISO 8601 in UTC, for example 2026-10-01T09:00:00Z. Time
parameters accept a date (2026-10-01) or a date and time with Z or an
offset. Write a + offset as %2B in a URL.
Ids are strings. Use them exactly as you receive them.
Scope. Everything is limited to the workspace your key belongs to. An id from another workspace is answered exactly like one that does not exist.
Scores and unlocking
A completed test appears as soon as the candidate finishes it, with its
completedAt time. Until the result is unlocked in Test Candidates, unlocked
is false and every score field is null: scorePercentage,
speedCompleted, questionsAnswered, questionsCorrect,
questionsIncorrect, questionsTotal, result and scores. This is the same
rule the app applies. See unlock results.
The API cannot unlock a result. To find out when one has been unlocked, ask for
candidates updated since your last check (see Keeping your system in sync,
below), or use the
resultUnlocked webhook.
Pagination
The list endpoints use cursors.
limit: rows per page, from 1 to 100. The default is 50.cursor: leave it out for the first page. If there are more rows, the response'smeta.nextCursoris a string: send it back unchanged ascursorto get the next page. Whenmeta.nextCursorisnull, you have reached the end.
Cursors are opaque, so do not build or edit them. Rows created while you are paging cannot move a page boundary, so nothing is skipped or repeated.
Rate limits
Each API key may make 120 requests a minute. Every authenticated response carries:
X-RateLimit-Limit: the allowance, 120.X-RateLimit-Remaining: requests left in the current minute.
Over the limit, the answer is 429 with the error code rate_limited and a
Retry-After header giving the seconds to wait. Requests that fail
authentication are not counted against a key.
Errors
Every error has the same shape:
{
"error": {
"code": "not_found",
"message": "No assessment with this id."
}
}
code is stable and meant for your code. message is for people and may change.
| Status | Code | When |
|---|---|---|
| 400 | invalid_parameter |
A query parameter is not valid, for example limit outside 1 to 100, an unknown status, or a time that is not ISO 8601 |
| 400 | invalid_cursor |
The cursor was changed or did not come from meta.nextCursor |
| 400 | invalid_json |
The invitation body is not a JSON object |
| 400 | invalid_idempotency_key |
The Idempotency-Key header is not 1 to 255 printable characters without spaces |
| 401 | unauthorized |
The credential is missing, malformed or wrong |
| 403 | workspace_unverified |
Invitations cannot be sent until someone in your workspace has confirmed their email address |
| 404 | not_found |
No such id in your workspace, or no endpoint at that path |
| 409 | idempotency_key_reused |
The Idempotency-Key was already used with a different body |
| 409 | idempotency_request_in_progress |
The first request with this Idempotency-Key is still running. Retry after the Retry-After seconds |
| 422 | validation_failed |
candidates is missing, empty or not a list |
| 422 | too_many_candidates |
More than 100 people in one invitation request |
| 422 | assessment_closed |
The assessment is closed or past its deadline, so it cannot take new invitations |
| 429 | rate_limited |
More than 120 requests in a minute with this key |
| 500 | server_error |
Something went wrong on our side |
List assessments
GET /v1/assessments
Your workspace's assessments, newest first.
| Parameter | Meaning |
|---|---|
limit |
Rows per page, 1 to 100. Default 50 |
cursor |
The previous page's meta.nextCursor |
status |
active or archived. Leave it out for both |
curl "https://www.testcandidates.com/api/v1/assessments?status=active" \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
{
"data": [
{
"id": "3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34",
"name": "Graduate analyst",
"status": "active",
"createdAt": "2026-09-01T09:00:00Z",
"deadline": null,
"tests": [
{ "slug": "numerical-reasoning", "name": "Numerical reasoning", "type": "library" },
{ "slug": "our-quiz", "name": "Our quiz", "type": "custom" }
],
"candidateCount": 42,
"inviteUrl": "https://www.testcandidates.com/a/grad-analyst"
}
],
"meta": { "nextCursor": "WyIyMDI2LTA5LTAxIDA5OjAwOjAwIiwxMl0" }
}
Fields
status:active, orarchivedonce the assessment has been closed.deadline: a date, ornullif there is none.tests: each test'sslug,nameandtype, which islibraryfor a Test Candidates test orcustomfor one your workspace wrote.candidateCount: candidates on the assessment. People invited who have not registered yet are not counted.inviteUrl: the shareable sign-up link, ornullwhen open sign-up is off.
Get an assessment
GET /v1/assessments/{id}
curl https://www.testcandidates.com/api/v1/assessments/3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34 \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
The response is { "data": { ... } } with the same fields as one row of the
list. An unknown id is 404 not_found.
Invite candidates to an assessment
POST /v1/assessments/{id}/invitations
Invites up to 100 people by email. Each person gets the same invitation email the app sends, and the same rules apply: an address already invited to this assessment, or already a candidate on it, is not invited again. Each person is answered separately, in the order you sent them, so one bad address does not stop the others.
Body
| Field | Meaning |
|---|---|
candidates |
A list of 1 to 100 people. Required |
candidates[].email |
Their email address. Required |
candidates[].firstName |
Optional, up to 255 characters |
candidates[].surname |
Optional, up to 255 characters |
Header
| Header | Meaning |
|---|---|
Idempotency-Key |
Optional, and recommended. A unique string for this request, so a retry cannot invite anyone twice. See Idempotent invitations, below |
curl -X POST https://www.testcandidates.com/api/v1/assessments/3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34/invitations \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c8e9f4a-2b1d-4e7a-9c3f-0a1b2c3d4e5f" \
-d '{
"candidates": [
{ "email": "sam.taylor@example.com", "firstName": "Sam", "surname": "Taylor" },
{ "email": "priya.shah@example.com", "firstName": "Priya", "surname": "Shah" }
]
}'
{
"data": [
{
"email": "sam.taylor@example.com",
"firstName": "Sam",
"surname": "Taylor",
"status": "invited",
"reason": null,
"candidateId": null,
"invitationUrl": "https://www.testcandidates.com/ca/sign-up/grad-analyst?invitation=..."
},
{
"email": "priya.shah@example.com",
"firstName": "Priya",
"surname": "Shah",
"status": "already_invited",
"reason": "already_candidate",
"candidateId": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"invitationUrl": null
}
],
"meta": {
"assessmentId": "3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34",
"invited": 1,
"alreadyInvited": 1,
"failed": 0
}
}
Each person's status
status |
reason |
Meaning |
|---|---|---|
invited |
null |
A new invitation was created and its email queued |
already_invited |
already_invited |
They already have an invitation to this assessment. Nothing was sent; invitationUrl is their existing link |
already_invited |
already_candidate |
They are already a candidate on this assessment. Nothing was sent; candidateId is set |
failed |
invalid_email |
The email address is missing or not valid |
failed |
invalid_name |
firstName or surname is not text, or is longer than 255 characters |
failed |
invalid_entry |
The entry is not an object |
email in the response is the address as it will be used, trimmed and in lower
case. invitationUrl is the personal link in their invitation email.
The whole request is refused with 422 assessment_closed when the
assessment is closed or past its deadline, and with 403 workspace_unverified
when nobody in your workspace has confirmed their email address yet.
Idempotent invitations
Networks fail, and a retried invitation request should not email anyone twice.
Send an Idempotency-Key header with any unique string up to 255 printable
characters, for example a UUID.
- Same key, same body, within 24 hours: you get the original response
again, with the header
Idempotent-Replayed: true, and nobody is invited again. - Same key, different body:
409 idempotency_key_reused. Use a new key for a new request. - Same key while the first request is still running:
409 idempotency_request_in_progress, with aRetry-Afterheader.
Keys belong to your workspace, so two of your API keys share them. Even without a key, an address already invited to an assessment is never invited to it twice.
List candidates
GET /v1/candidates
Your workspace's candidates, newest first, each with their assessments and results.
| Parameter | Meaning |
|---|---|
limit |
Rows per page, 1 to 100. Default 50 |
cursor |
The previous page's meta.nextCursor |
assessment |
Only candidates on this assessment (its id). An unknown id is 404 not_found |
email |
Only the candidate with this email address (not case sensitive) |
updated_since |
Only candidates whose record changed, or who completed a test or had a result unlocked on one of your assessments, at or after this time |
curl "https://www.testcandidates.com/api/v1/candidates?updated_since=2026-10-01T09:00:00Z" \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
The response is { "data": [ ... ], "meta": { "nextCursor": ... } }, where each
row has the same shape as the single candidate below.
Get a candidate
GET /v1/candidates/{id}
curl https://www.testcandidates.com/api/v1/candidates/9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
{
"data": {
"id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"email": "priya.shah@example.com",
"firstName": "Priya",
"surname": "Shah",
"createdAt": "2026-09-05T10:00:00Z",
"updatedAt": "2026-09-10T13:00:00Z",
"reportUrl": "https://www.testcandidates.com/candidates/9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"assessments": [
{
"id": "3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34",
"name": "Graduate analyst",
"status": "completed",
"completedAt": "2026-09-10T12:00:00Z",
"unlocked": false,
"results": [
{
"id": "1201",
"testSlug": "numerical-reasoning",
"testName": "Numerical reasoning",
"testType": "library",
"completedAt": "2026-09-10T11:00:00Z",
"unlocked": true,
"unlockedAt": "2026-09-10T13:00:00Z",
"scorePercentage": 72.5,
"speedCompleted": 600,
"questionsAnswered": 20,
"questionsCorrect": 15,
"questionsIncorrect": 5,
"questionsTotal": 20,
"result": null,
"scores": null
},
{
"id": "1202",
"testSlug": "verbal-reasoning",
"testName": "Verbal reasoning",
"testType": "library",
"completedAt": "2026-09-10T12:00:00Z",
"unlocked": false,
"unlockedAt": null,
"scorePercentage": null,
"speedCompleted": null,
"questionsAnswered": null,
"questionsCorrect": null,
"questionsIncorrect": null,
"questionsTotal": null,
"result": null,
"scores": null
}
]
}
]
}
}
Fields
reportUrl: the candidate's page in the Test Candidates app. Opening it needs a sign-in.assessments: only your workspace's assessments, even if the same person has taken tests for another employer.assessments[].status:not_started,in_progressorcompleted.assessments[].unlocked:truewhen there is at least one result and every result is unlocked.results[]: one entry per completed test.speedCompletedis in seconds.resultis the test's result label, where the test has one, andscoresholds detailed scores, where the test has them (for example personality traits). Every score field isnulluntil that result is unlocked.
List completed tests
GET /v1/results
Completed tests on your assessments, oldest first, so you can sync forward.
| Parameter | Meaning |
|---|---|
limit |
Rows per page, 1 to 100. Default 50 |
cursor |
The previous page's meta.nextCursor |
since |
Only tests completed at or after this time |
unlocked_since |
Only results unlocked at or after this time, so their scores are available. Sorted by unlock time, with no cursor: use limit |
order |
asc (the default, oldest first) or desc (newest first) |
assessment |
Only results on this assessment (its id). An unknown id is 404 not_found |
curl "https://www.testcandidates.com/api/v1/results?since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
{
"data": [
{
"id": "1201",
"testSlug": "numerical-reasoning",
"testName": "Numerical reasoning",
"testType": "library",
"completedAt": "2026-10-01T11:00:00Z",
"unlocked": false,
"unlockedAt": null,
"scorePercentage": null,
"speedCompleted": null,
"questionsAnswered": null,
"questionsCorrect": null,
"questionsIncorrect": null,
"questionsTotal": null,
"result": null,
"scores": null,
"candidate": {
"id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"email": "priya.shah@example.com",
"firstName": "Priya",
"surname": "Shah"
},
"assessment": {
"id": "3f6c1d2e-7a4b-4c1e-9b0a-2d5e8f1a6c34",
"name": "Graduate analyst"
},
"reportUrl": "https://www.testcandidates.com/candidates/9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d"
}
],
"meta": { "nextCursor": null }
}
Each row has the result fields described under Get a candidate, above, plus who took it and on which assessment.
List finished assessments
GET /v1/completions
Candidates who finished every test of one of your assessments, newest first.
It suits polling tools: there is no cursor. You get the limit most recent,
narrowed by since and assessment. The id joins the candidate id and the
assessment id and never changes, so you can use it to spot ones you have seen.
Scores follow the usual unlock rule.
| Parameter | Meaning |
|---|---|
limit |
How many, 1 to 100. Default 50 |
since |
Only assessments finished at or after this time |
assessment |
Only this assessment (its id). An unknown id is 404 not_found |
curl "https://www.testcandidates.com/api/v1/completions?since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $TC_KEY:$TC_SECRET"
{
"data": [
{
"id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d:3f2e1d0c-9b8a-4c7d-8e6f-5a4b3c2d1e0f",
"candidate": {
"id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"email": "grace@example.com",
"firstName": "Grace",
"surname": "Hopper"
},
"assessment": { "id": "3f2e1d0c-9b8a-4c7d-8e6f-5a4b3c2d1e0f", "name": "Graduate analyst" },
"completedAt": "2026-10-01T11:20:00Z",
"unlocked": false,
"results": [ ... ],
"reportUrl": "https://www.testcandidates.com/candidates/9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d"
}
]
}
results holds the same result objects as Get a candidate.
Check a key
GET /v1/me
Returns the workspace the key belongs to. Connection tools use it to test a key and to name the connection.
{ "data": { "workspace": { "id": "6f1c...", "name": "Northbank Ltd" } } }
Keeping your system in sync
A simple pattern that needs no webhooks:
- New results. Call
GET /v1/results?since=with the lastcompletedAtyou stored, followmeta.nextCursorto the end, and store the newestcompletedAtfor next time. - Newly unlocked scores.
/v1/resultslists a test when it is completed, and its scores arenulluntil it is unlocked. To pick up scores unlocked later, callGET /v1/candidates?updated_since=with the time of your last check.
Because since and updated_since include the time you give, you may see a
row you already have. Match on id and update it rather than adding it twice.
The original endpoint
GET /candidates
The original endpoint, at https://www.testcandidates.com/api/candidates or
https://api.picked.ai/assessment/candidates, still works unchanged for
existing integrations. It accepts the same credentials, in either form.
| Parameter | Meaning |
|---|---|
page |
The page number |
per_page |
Rows per page, up to 100. Default 50 |
sort_by |
Only created_at |
sort_order |
asc or desc. Default desc |
It has its own error shape, { "status": "403", "code": "<message>" }, and
answers a missing, malformed or wrong credential with 403. Scores are hidden
until a result is unlocked, as on every other route.
New integrations should use GET /v1/candidates.