Skip to content

API reference

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

Last updated 6 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 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'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
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, 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

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. 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:

  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.