Skip to content

API reference

Every endpoint of the Test Candidates API, with parameters, example requests and responses, errors, pagination, rate limits, idempotent invitations and the sandbox.

Last updated 19 October 2026

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 public developer documentation is generated from that file and adds a ten-minute quick start and a changelog.

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.

Sandbox keys

A key is live or sandbox (see create and use an API key). With a sandbox key, POST .../invitations creates test candidates, every read returns test candidates only, results unlock free, and webhook envelopes carry "sandbox": true. GET /v1/me reports the key's mode. A workspace may create 25 test candidates in any 30 days: 422 sandbox_limit says when that is used up. Live and test never mix: a sandbox key cannot invite one of your live candidates (reason: live_candidate) and a live key cannot invite a test candidate (reason: sandbox_candidate).

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's meta.nextCursor is a string: send it back unchanged as cursor to get the next page. When meta.nextCursor is null, 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; or, when creating an assessment, name, tests or deadline is missing or wrong
422 unknown_test A test slug that is not in GET /v1/tests
422 sandbox_limit The workspace has used its 25 test candidates for the last 30 days
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 tests

GET /v1/tests

The library of tests an assessment can be built from, by name: the same tests the app offers. Use a test's slug in Create an assessment. The list is the same for every workspace and has no cursor.

Parameter Meaning
type Only tests of one type, for example aptitude, personality, soft-skills, software, programming, typing or language
curl https://www.testcandidates.com/api/v1/tests \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"
{
  "data": [
    {
      "slug": "numerical-reasoning",
      "name": "Numerical reasoning",
      "type": "aptitude",
      "format": "choice",
      "minutes": 20,
      "timed": true,
      "questionCount": 20,
      "description": "Working with numbers, tables and charts."
    }
  ],
  "meta": { "total": 144 }
}

Create an assessment

POST /v1/assessments

Creates an assessment from one to ten library tests, exactly as the app does. Candidates sit the tests in the order given. The answer is the assessment as Get an assessment returns it, with 201, including inviteUrl (the public sign-up link) and the id to invite candidates with.

Body field Meaning
name Required. Shown to candidates and in the app, up to 255 characters
tests Required. One to ten test slugs from GET /v1/tests, in sitting order
deadline Optional. The last day candidates may start, YYYY-MM-DD, not in the past
curl -X POST https://www.testcandidates.com/api/v1/assessments \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"name": "Sales associate", "tests": ["numerical-reasoning", "drives"]}'

A slug that is not in the catalogue is 422 unknown_test; a missing or wrong field is 422 validation_failed.

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, or archived once the assessment has been closed.
  • deadline: a date, or null if there is none.
  • tests: each test's slug, name and type, which is library for a Test Candidates test or custom for 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, or null when 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
failed live_candidate A sandbox key cannot invite one of your live candidates
failed sandbox_candidate A live key cannot invite a test candidate

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 a Retry-After header.

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_progress or completed.
  • assessments[].unlocked: true when there is at least one result and every result is unlocked.
  • results[]: one entry per completed test. speedCompleted is in seconds. result is the test's result label, where the test has one, and scores holds detailed scores, where the test has them (for example personality traits). Every score field is null until 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 unless you ask for order=asc. It suits polling tools: there is no cursor. You get up to limit, narrowed by since and assessment. If you poll with since, use order=asc and keep the last completedAt, so a busy spell of more than limit finishes is never skipped. 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
order desc (the default, newest first) or asc (oldest first)
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 and the key's mode, live or sandbox. Connection tools use it to test a key and to name the connection. For a sandbox key, sandbox says how many test candidates the workspace may still create in the 30-day window; for a live key it is null.

{ "data": { "workspace": { "id": "6f1c...", "name": "Northbank Ltd" }, "mode": "sandbox", "sandbox": { "limit": 25, "windowDays": 30, "remaining": 23 } } }

Keeping your system in sync

A simple pattern that needs no webhooks:

  1. New results. Call GET /v1/results?since= with the last completedAt you stored, follow meta.nextCursor to the end, and store the newest completedAt for next time.
  2. Newly unlocked scores. /v1/results lists a test when it is completed, and its scores are null until it is unlocked. To pick up scores unlocked later, call GET /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.