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.
- 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 - Check the key.
GET /v1/menames 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" - Pick a test.
GET /v1/testslists every test with itsslug, 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" - Create an assessment, or use one you already have.
POST /v1/assessmentstakes a name and the test slugs in sitting order. The answer carries the assessmentidyou invite with, andinviteUrl, a public sign-up link you can put in your own product instead of inviting by email.
To use an existing assessment, list them withcurl -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"}'GET /v1/assessmentsand take itsid. - Invite a candidate. Invite yourself.
POST /v1/assessments/{assessmentId}/invitationssends the same invitation email the app sends. With a sandbox key the person becomes a test candidate. Send anIdempotency-Keyso 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"}]}' - 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.
- 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.
assessmentCompletedarrives when the candidate finishes every test, with the report link;resultUnlockedarrives when a score becomes readable. Sandbox events carry"sandbox": true. See webhooks. - Poll.
GET /v1/completionslists finished assessments newest first;GET /v1/resultslists completed tests with a cursor, andunlocked_sincepicks up scores as they unlock.
- Webhook. On the integrations page add an HTTPS address and choose the events you want.
- Fetch the report.
GET /v1/candidates/{candidateId}returns every test the candidate took on your assessments, with scores once unlocked, andreportUrl, 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": trueat 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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
type | query | string | Only tests of one type, for example |
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 field | Type | Meaning |
|---|---|---|
name required | string | Shown to candidates and in the app, for example the role. |
tests required | list of string | Test slugs from |
deadline | string or null, date | Last day candidates may start, |
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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
limit | query | integer | Rows per page, 1 to 100. |
cursor | query | string | The |
status | query | active | 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
| Parameter | In | Type | Meaning |
|---|---|---|---|
assessmentId required | path | string | 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.reasonisalready_invited(an invitation exists, and its link is returned) oralready_candidate(they are on the assessment;candidateIdis set).failed: nothing was sent.reasonisinvalid_email,invalid_name,invalid_entry,live_candidate(a sandbox key cannot invite one of your live candidates) orsandbox_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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
assessmentId required | path | string | The assessment's id. |
Idempotency-Key | header | string | A unique string for this request, so a retry cannot invite anyone twice. See Idempotent invitations above. |
| Body field | Type | Meaning |
|---|---|---|
candidates required | list 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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
limit | query | integer | Rows per page, 1 to 100. |
cursor | query | string | The |
assessment | query | string | Only candidates on this assessment (its id). |
email | query | string, email | Only the candidate with this email address (not case sensitive). |
updated_since | query | string, 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
| Parameter | In | Type | Meaning |
|---|---|---|---|
candidateId required | path | string |
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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
limit | query | integer | Rows per page, 1 to 100. |
since | query | string, date-time | Only assessments finished at or after this time. |
order | query | asc | desc |
|
assessment | query | string | 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.
| Parameter | In | Type | Meaning |
|---|---|---|---|
limit | query | integer | Rows per page, 1 to 100. |
cursor | query | string | The |
since | query | string, date-time | Only tests completed at or after this time. |
unlocked_since | query | string, date-time | Only results unlocked at or after this time (so their scores are available). Sorted by unlock time; no cursor, use |
order | query | asc | desc |
|
assessment | query | string | 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.
| Status | Code | When |
|---|---|---|
| 400 | invalid_parameter | A 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. |
| 400 | invalid_cursor | The cursor was changed or did not come from meta.nextCursor. |
| 400 | invalid_json | The request 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 the 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 | The body is well formed but a field is missing or wrong: candidates, name, tests or deadline. |
| 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. |
| 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. |
| 429 | rate_limited | More than 120 requests in a minute with this key. |
| 500 | server_error | Something 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/mereportsmode(live or sandbox) and the sandbox allowance.Invitation
reasongainslive_candidateandsandbox_candidate; new errorssandbox_limitandunknown_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.