Skip to content

Build with the Test Candidates API

Put a hiring test inside your own product. List tests, create an assessment, invite a candidate and receive the result by webhook, with a sandbox that never charges. A first result in about ten minutes.

Start the quick start

A free account and a sandbox key. No card.

Quick start: a result in ten minutes

You need a Test Candidates account (free, no card) and any HTTP client. The steps below use curl and a sandbox key, so nothing is charged and nothing touches your live candidates.

  1. Create an account and a sandbox key. Sign up, confirm your email, then open the account menu, choose Integrations, and create a key with Sandbox key (test mode) ticked. Copy the key and secret: the secret is shown once. Put them in your shell:
    export TC_KEY=SBX...        # your sandbox key
    export TC_SECRET=...        # its secret
  2. Check the key. GET /v1/me names your workspace and says the key is in sandbox mode.
    curl "https://www.testcandidates.com/api/v1/me" \
      -H "Authorization: Bearer $TC_KEY:$TC_SECRET"
  3. Pick a test. GET /v1/tests lists every test with its slug, type, minutes and question count. For a first run choose a short one.
    curl "https://www.testcandidates.com/api/v1/tests" \
      -H "Authorization: Bearer $TC_KEY:$TC_SECRET"
  4. Create an assessment, or use one you already have. POST /v1/assessments takes a name and the test slugs in sitting order. The answer carries the assessment id you invite with, and inviteUrl, a public sign-up link you can put in your own product instead of inviting by email.
    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"],"deadline":"2026-12-31"}'
    To use an existing assessment, list them with GET /v1/assessments and take its id.
  5. Invite a candidate. Invite yourself. POST /v1/assessments/{assessmentId}/invitations sends the same invitation email the app sends. With a sandbox key the person becomes a test candidate. Send an Idempotency-Key so a retry can never invite twice.
    curl -X POST "https://www.testcandidates.com/api/v1/assessments/<assessmentId>/invitations" \
      -H "Authorization: Bearer $TC_KEY:$TC_SECRET" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: <a unique id for this request>" \
      -d '{"candidates":[{"email":"sam.taylor@example.com","firstName":"Sam","surname":"Taylor"},{"email":"priya.shah@example.com","firstName":"Priya","surname":"Shah"}]}'
  6. Sit the test. Open the invitation email and take the test. A test candidate takes the real test, with the real candidate experience, so you see exactly what your users' applicants will see.
  7. Receive the result. Two ways, and most products use both:
    • Webhook. On the integrations page add an HTTPS address and choose the events you want. assessmentCompleted arrives when the candidate finishes every test, with the report link; resultUnlocked arrives when a score becomes readable. Sandbox events carry "sandbox": true. See webhooks.
    • Poll. GET /v1/completions lists finished assessments newest first; GET /v1/results lists completed tests with a cursor, and unlocked_since picks up scores as they unlock.
  8. Fetch the report. GET /v1/candidates/{candidateId} returns every test the candidate took on your assessments, with scores once unlocked, and reportUrl, the report page in Test Candidates. Admins can also copy a share link to the report from that page, which opens without a sign-in.

When the flow works, create a live key and swap it in. Nothing else changes. Live results beyond your free allowance are billed per candidate at the published price, one charge per candidate however many tests they sit.

Sandbox and test candidates

A workspace can hold live keys and sandbox keys side by side, like a payment provider's test keys. Create a sandbox key on the integrations page (it starts with SBX). With a sandbox key:

  • invitations create test candidates: they get the real invitation email and sit real tests, but their results are never charged and never count towards your free results;
  • reads return test candidates and their results only; a live key never sees them, so your production sync is never polluted by tests;
  • assessments are shared, so a live key sees what a sandbox key created;
  • webhook events for test candidates carry "sandbox": true at the top level of the envelope.

GET /v1/me says which mode a key is in. 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 an address that is a live candidate of the workspace (reason: live_candidate), and a live key cannot invite a test candidate (reason: sandbox_candidate).

Test candidates show a Test badge in the app and can be deleted there like any other candidate.

Authentication

Create an API key in the app under Settings, Integrations. A key has two parts, a key and a secret. Send both on every request, in either form:

Authorization: Bearer <key>:<secret>
Authorization: Basic <base64 of key:secret>

A missing, malformed or wrong credential is answered with 401 and the error code unauthorized. Keep the secret on your server: never put it in a web page or a mobile app.

Only admins can create keys. A key reads the whole workspace, in its mode, so give partners and systems a key of their own and delete it when they no longer need it. Regenerating a key stops the old one at once. More in keep your API key safe.

Rate limits

Each API key may make 120 requests a minute. Every 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.

Pagination

List endpoints use cursors. Pass limit (1 to 100, default 50). If there are more rows, the response's meta.nextCursor is a string: pass it back unchanged as cursor to get the next page. When meta.nextCursor is null you have reached the end. Cursors are opaque: do not build or edit them. Rows created while you page cannot move a page boundary, so nothing is skipped or repeated.

Scores and unlocking

A completed test is visible as soon as the candidate finishes it, with its completedAt time. Its scores stay null, and unlocked stays false, until the result is unlocked in Test Candidates. This is the same rule the app applies.

Idempotent invitations

Send an Idempotency-Key header (any unique string up to 255 printable characters, for example a UUID) with POST .../invitations to make retries safe. Within 24 hours, repeating the request with the same key and the same body returns the original response, with the header Idempotent-Replayed: true, and invites nobody again. The same key with a different body is refused with 409 idempotency_key_reused. Keys belong to your workspace, so two of your API keys share them; a key used with a sandbox key cannot be reused with a live key, so use new keys when you go live.

Times

Times are ISO 8601 in UTC, for example 2026-10-01T09:00:00Z. Query parameters accept a date (2026-10-01) or a date-time with Z or an offset. URL-encode a + offset as %2B.

Webhooks

We POST a JSON envelope to the HTTPS address saved on your integrations page, signed with your webhook secret in the X-Webhook-Signature header, with a timestamp, retries and a 30-day delivery log you can resend from. The events are candidateCreated, candidateInvited, assessmentStarted, testCompleted, assessmentCompleted and resultUnlocked. Locked results carry unlocked: false and null scores, exactly as the API does. Sandbox events carry "sandbox": true at the top level of the envelope; live envelopes are unchanged.

The payload of every event, the signature check with a worked example, and how to test an address are in the webhooks guide.

Branding: your name or ours

Every page a candidate sees can carry the workspace's logo and brand colour, with a preview before anyone is invited. The setting lives in the app, under Branding, and applies to invitations, the candidate hub and the test pages. A product that embeds our tests therefore has two choices:

  • Under your brand. Set your logo and colour on the workspace. Candidates see your name on the invitation and the tests, and the report says the assessment was run through Test Candidates in the footer.
  • Under ours. Leave branding off. Candidates see Test Candidates throughout.

Branding is per workspace, so a product that gives each of its own customers a workspace can let each one brand its own. How to set it up: company branding.

Reference

Base URL https://www.testcandidates.com/api. Every response is JSON: one item as { "data": { ... } }, a list as { "data": [ ... ], "meta": { ... } }, an error as { "error": { "code", "message" } }. The same contract as an OpenAPI 3.1 file.

Workspace

GET /v1/meThe workspace this key belongs to, and the key's mode

Use it to check a key works, to label a connection, and to see whether the key is live or sandbox.

Request

curl "https://www.testcandidates.com/api/v1/me" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

Response 200

{
  "data": {
    "workspace": {
      "id": "6f1c0a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
      "name": "Northbank Ltd"
    },
    "mode": "sandbox",
    "sandbox": {
      "limit": 25,
      "windowDays": 30,
      "remaining": 23
    }
  }
}

200 The workspace and mode.401 Missing, malformed or wrong credential.429 More than 120 requests in a minute with this API key.

Tests

GET /v1/testsList the tests an assessment can be built from

The library catalogue, by name: the same tests the app offers. Use a test's slug in POST /v1/assessments. The list is the same for every workspace and small enough to have no cursor.

ParameterInTypeMeaning
typequerystring

Only tests of one type, for example aptitude, personality, soft-skills, software, programming, typing or language.

Request

curl "https://www.testcandidates.com/api/v1/tests" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

Response 200

{
  "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
  }
}

200 Every test, by name.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.429 More than 120 requests in a minute with this API key.

Assessments

POST /v1/assessmentsCreate an assessment

Creates an assessment from one to ten library tests, exactly as the app does. Candidates sit the tests in the order given. The response is the assessment as GET /v1/assessments/{assessmentId} returns it, including inviteUrl (the public sign-up link) and the id to invite candidates with.

Body fieldTypeMeaning
name requiredstring

Shown to candidates and in the app, for example the role.

tests requiredlist of string

Test slugs from GET /v1/tests, in sitting order.

deadlinestring or null, date

Last day candidates may start, YYYY-MM-DD, or omit for none.

Request

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"],"deadline":"2026-12-31"}'

Response 201

{
  "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"
  }
}

201 The new assessment.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.422 The body cannot be carried out. `validation_failed` (name, tests or deadline), or `unknown_test` (a slug that is not in `GET /v1/tests`).429 More than 120 requests in a minute with this API key.

GET /v1/assessmentsList assessments

Your workspace's assessments, newest first.

ParameterInTypeMeaning
limitqueryinteger

Rows per page, 1 to 100.

cursorquerystring

The meta.nextCursor of the previous page, unchanged.

statusqueryactive | archived

Only active or only archived assessments.

Request

curl "https://www.testcandidates.com/api/v1/assessments" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

Response 200

{
  "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"
        }
      ],
      "candidateCount": 42,
      "inviteUrl": "https://www.testcandidates.com/a/grad-analyst"
    }
  ],
  "meta": {
    "nextCursor": "WyIyMDI2LTA5LTAxIDA5OjAwOjAwIiwxMl0"
  }
}

200 A page of assessments.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.429 More than 120 requests in a minute with this API key.

GET /v1/assessments/{assessmentId}Get an assessment

ParameterInTypeMeaning
assessmentId requiredpathstring

The assessment's id.

Request

curl "https://www.testcandidates.com/api/v1/assessments/<assessmentId>" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

Response 200

{
  "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"
  }
}

200 The assessment.401 Missing, malformed or wrong credential.404 No such id in your workspace.429 More than 120 requests in a minute with this API key.

Invitations

POST /v1/assessments/{assessmentId}/invitationsInvite candidates to an assessment

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 entry is answered on its own, in the order sent, so one bad address does not stop the others.

status is one of:

  • invited: a new invitation was created and its email queued.
  • already_invited: nothing was sent. reason is already_invited (an invitation exists, and its link is returned) or already_candidate (they are on the assessment; candidateId is set).
  • failed: nothing was sent. reason is invalid_email, invalid_name, invalid_entry, live_candidate (a sandbox key cannot invite one of your live candidates) or sandbox_candidate (a live key cannot invite a test candidate).

With a sandbox key the people invited become test candidates (see Sandbox above); 422 sandbox_limit means the workspace's test allowance for the last 30 days is used up.

The whole request is refused when the assessment is closed or past its deadline (422 assessment_closed) and when nobody in your workspace has confirmed their email address yet (403 workspace_unverified). Invitations are not charged; a candidate is charged when their result is unlocked.

ParameterInTypeMeaning
assessmentId requiredpathstring

The assessment's id.

Idempotency-Keyheaderstring

A unique string for this request, so a retry cannot invite anyone twice. See Idempotent invitations above.

Body fieldTypeMeaning
candidates requiredlist of object

Request

curl -X POST "https://www.testcandidates.com/api/v1/assessments/<assessmentId>/invitations" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: <a unique id for this request>" \
  -d '{"candidates":[{"email":"sam.taylor@example.com","firstName":"Sam","surname":"Taylor"},{"email":"priya.shah@example.com","firstName":"Priya","surname":"Shah"}]}'

Response 200

{
  "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=Xk2..."
    },
    {
      "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
  }
}

200 One result per person sent, in the same order.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.403 The workspace cannot do this yet.404 No such id in your workspace.409 The Idempotency-Key was used with a different body, or its first request is still running.422 The body is well formed but cannot be carried out.429 More than 120 requests in a minute with this API key.

Candidates

GET /v1/candidatesList candidates

Your workspace's candidates, newest first, each with their assessments and results.

ParameterInTypeMeaning
limitqueryinteger

Rows per page, 1 to 100.

cursorquerystring

The meta.nextCursor of the previous page, unchanged.

assessmentquerystring

Only candidates on this assessment (its id).

emailquerystring, email

Only the candidate with this email address (not case sensitive).

updated_sincequerystring, date-time

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. Use it to pick up newly available scores.

Request

curl "https://www.testcandidates.com/api/v1/candidates" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

200 A page of candidates.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.404 No such id in your workspace.429 More than 120 requests in a minute with this API key.

GET /v1/candidates/{candidateId}Get a candidate

ParameterInTypeMeaning
candidateId requiredpathstring

Request

curl "https://www.testcandidates.com/api/v1/candidates/<candidateId>" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

Response 200

{
  "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
          }
        ]
      }
    ]
  }
}

200 The candidate, with only your workspace's assessments and results.401 Missing, malformed or wrong credential.404 No such id in your workspace.429 More than 120 requests in a minute with this API key.

Results

GET /v1/completionsList finished assessments

Candidates who finished every test of one of your assessments, newest first unless order=asc. Built for polling: there is no cursor; you get up to limit, narrowed by since and assessment. When polling with since, use order=asc and keep the last completedAt, so a burst of more than limit is not skipped. The id (candidate id and assessment id) never changes, so you can de-duplicate on it. Scores are null until a result is unlocked.

ParameterInTypeMeaning
limitqueryinteger

Rows per page, 1 to 100.

sincequerystring, date-time

Only assessments finished at or after this time.

orderqueryasc | desc

desc (default, newest first) or asc (oldest first).

assessmentquerystring

Only this assessment (its id).

Request

curl "https://www.testcandidates.com/api/v1/completions" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

200 The most recent finished assessments.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.404 No such id in your workspace.429 More than 120 requests in a minute with this API key.

GET /v1/resultsList completed tests

Completed tests on your assessments, oldest first, so you can sync forward: start from since, follow meta.nextCursor to the end, and next time start from the last completedAt you stored (or keep the cursor). Use order=desc for newest first. Scores are null until a result is unlocked; to learn when that happens, use unlocked_since.

ParameterInTypeMeaning
limitqueryinteger

Rows per page, 1 to 100.

cursorquerystring

The meta.nextCursor of the previous page, unchanged.

sincequerystring, date-time

Only tests completed at or after this time.

unlocked_sincequerystring, date-time

Only results unlocked at or after this time (so their scores are available). Sorted by unlock time; no cursor, use limit.

orderqueryasc | desc

asc (default, oldest first) or desc (newest first).

assessmentquerystring

Only results on this assessment (its id).

Request

curl "https://www.testcandidates.com/api/v1/results" \
  -H "Authorization: Bearer $TC_KEY:$TC_SECRET"

200 A page of results.400 A query parameter, cursor, body or header is not valid.401 Missing, malformed or wrong credential.404 No such id in your workspace.429 More than 120 requests in a minute with this API 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. An id that belongs to another workspace is answered exactly like an id that does not exist: 404 not_found.

StatusCodeWhen
400invalid_parameterA query parameter is not valid, for example limit outside 1 to 100, an unknown status or type, or a time that is not ISO 8601.
400invalid_cursorThe cursor was changed or did not come from meta.nextCursor.
400invalid_jsonThe request body is not a JSON object.
400invalid_idempotency_keyThe Idempotency-Key header is not 1 to 255 printable characters without spaces.
401unauthorizedThe credential is missing, malformed or wrong.
403workspace_unverifiedInvitations cannot be sent until someone in the workspace has confirmed their email address.
404not_foundNo such id in your workspace, or no endpoint at that path.
409idempotency_key_reusedThe Idempotency-Key was already used with a different body.
409idempotency_request_in_progressThe first request with this Idempotency-Key is still running. Retry after the Retry-After seconds.
422validation_failedThe body is well formed but a field is missing or wrong: candidates, name, tests or deadline.
422too_many_candidatesMore than 100 people in one invitation request.
422assessment_closedThe assessment is closed or past its deadline, so it cannot take new invitations.
422unknown_testA test slug that is not in GET /v1/tests.
422sandbox_limitThe workspace has used its 25 test candidates for the last 30 days.
429rate_limitedMore than 120 requests in a minute with this key.
500server_errorSomething went wrong on our side.

Changelog

1.1.0 2026-10-19

  • Sandbox keys: self-serve test mode that never charges and keeps test candidates apart from live ones.

  • GET /v1/tests: the library of tests an assessment can be built from.

  • POST /v1/assessments: create an assessment from test slugs.

  • GET /v1/me reports mode (live or sandbox) and the sandbox allowance.

  • Invitation reason gains live_candidate and sandbox_candidate; new errors sandbox_limit and unknown_test.

  • Public developer documentation at https://www.testcandidates.com/developers/.

1.0.0 2026-10-06

  • First release: assessments, invitations with Idempotency-Key, candidates, results, completions and me.

Changes are additive. A field or endpoint is never removed from version 1; anything we retire is marked deprecated here first and keeps working. Questions: support@testcandidates.com.

Send your first assessment today

5 free candidate results · No credit card required