The platform API
Order an assessment from your ATS. Get a score you can defend.
The iTestHub client API is five endpoints, one signed webhook and one key. Your system orders the test, the candidate gets a link, and you pull back a structured result that names the exact test version and norm group it was scored against — optionally without ever sending us a candidate's name.
curl -X POST "$ITESTHUB/api/v1/orders" \
-H "Authorization: Bearer $ITESTHUB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ats-application-8891" \
-d '{"candidate": {"first_name": "Ada", "last_name": "Lovelace",
"email": "ada@example.com"},
"assessments": ["verbal-reasoning"],
"reference": "REQ-4471"}'
{
"id": "ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123",
"reference": "REQ-4471",
"expires_at": "2026-10-15T17:00:00Z",
"candidate_url": "https://app.itesthub.com/clients/acme/candidate_hub/8f2c19a4-5b6d-4e7f-9a0b-1c2d3e4f5a6b/",
"assessments": [
{
"id": "res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d",
"assessment_code": "verbal-reasoning",
"status": "pending"
}
]
}
The journey
Six steps, and we are honest about which ones are yours
An integration is a handful of HTTP calls and one decision about who talks to the candidate. Here is the whole of it, in order.
-
Your ATS orders the test
One
POSTwhen the applicant reaches the stage you test at. You choose the assessments and set the completion deadline. -
We return the link and the deadline
candidate_urlandexpires_atcome back on the201, with an id for the order and an id for each assessment on it. -
The invitation goes out
Yours by default — your branding, your sender domain, your reminder schedule. Set
modetoidentified_with_invitationon the order and we send it instead. An anonymous order is always yours to send: we have no address to send one to. -
The candidate sits the assessment
On our platform, on any device the assessment allows, with adjustments applied if they have them — and to WCAG 2.2 AA throughout.
-
A signed webhook fires
assessment.completed, HMAC-signed, retried with backoff, with a delivery record per attempt. Or poll the order if you would rather not run an endpoint. -
You pull the result
Raw and standardised scores, percentile and band, any component breakdown — and the provenance of every one of those numbers. The PDF report too, if you want it.
We do not email your candidates unless you ask
For an API order, the default is that iTestHub sends nothing. That is a design decision, not a gap. An ATS vendor has spent years on one sender reputation, one template system and one reminder cadence, and a second system emailing candidates from a domain nobody recognises undoes all of it. You get the link and the deadline; the message is yours.
If you would rather we ran invitations and reminders — many employers integrating their own ATS
would — order with mode set to identified_with_invitation, and we use the
same invitation path the iTestHub web app already uses. It is one field, per order, and the order
comes back carrying invitation_sent, so you know whether the email really went.
One link per candidate, and what the deadline really does
candidate_url opens the candidate's own hub, which lists everything they have been
given. Order more assessments for the same person under the same reference and they
are added to that hub; the same link comes back.
expires_at is the completion deadline you set and the date to print in your
invitation. Be aware of one honest limitation: in this version we do not hard-close the link at
that instant. An unfinished assessment reports expired from then on, and that status
is what your process should act on.
Quickstart
One key, four calls
Everything below is copied from responses the API really produces — the same payloads our own test suite asserts on. Paste it and it works.
First
Get a key
We issue it. A key belongs to exactly one client, carries only the scopes your integration uses, and is shown once — we store a SHA-256 hash and nothing else, so nobody here can read it back to you.
python manage.py create_client_api_key --client acme --name "Acme ATS production"
# Created ith_live_7f3a9c2b for Acme with scopes: assessments:read,
# orders:write, reports:read, results:read.
# Copy it now; it is not stored and cannot be shown again.
export ITESTHUB_KEY=ith_live_7f3a9c2b_S3cr3tV4lu3ThatIsOnlyEverShownOnce...
export ITESTHUB=https://app.itesthub.com
The part before the last underscore — ith_live_7f3a9c2b — is the key's public id. It is safe in your logs and in a support conversation; the half after it is the secret.
Call 1
See what you can order
Exactly what a client admin can choose from in the web app — there is no separate API catalogue to keep in step. Each row carries the code you order by, how long it takes, the device it needs and the norm group its scores are compared against.
curl -s "$ITESTHUB/api/v1/assessments" \
-H "Authorization: Bearer $ITESTHUB_KEY"
{
"data": [
{
"code": "verbal-reasoning",
"name": "Verbal Reasoning",
"type": "ability",
"description": "Measures the ability to draw accurate conclusions from written business information.",
"time_estimate_minutes": 20,
"time_limit_minutes": 18,
"timed": true,
"device_requirement": "large_recommended",
"norm_group": "General population (N=10,380)",
"norms_provisional": false
}
]
}
Call 2
Order a test for a candidate
You get back the link to send them. mode is optional and defaults to
identified, which is the call below: we hold the details, we send no email.
Send an Idempotency-Key and a retry is free: replaying it with the same body returns
the original order and creates nothing — no second candidate, no second assignment, no second
invitation for you to send.
curl -s -X POST "$ITESTHUB/api/v1/orders" \
-H "Authorization: Bearer $ITESTHUB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ats-application-8891" \
-d '{
"candidate": {"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com"},
"assessments": ["verbal-reasoning"],
"reference": "REQ-4471",
"expires_at": "2026-10-15T17:00:00Z"
}'
{
"id": "ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123",
"mode": "identified",
"reference": "REQ-4471",
"created_at": "2026-09-18T10:04:11Z",
"expires_at": "2026-10-15T17:00:00Z",
"candidate": {"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com"},
"candidate_url": "https://app.itesthub.com/clients/acme/candidate_hub/8f2c19a4-5b6d-4e7f-9a0b-1c2d3e4f5a6b/",
"invitation_sent": false,
"link_first_opened_at": null,
"assessments": [
{
"id": "res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d",
"assessment_code": "verbal-reasoning",
"assessment_name": "Verbal Reasoning",
"status": "pending",
"started_at": null,
"completed_at": null,
"candidate_url": "https://app.itesthub.com/clients/acme/candidate_hub/8f2c19a4-5b6d-4e7f-9a0b-1c2d3e4f5a6b/"
}
]
}
Call 3
Follow the order
Each assessment moves pending → in_progress → completed, or
expired if your deadline passes first. With webhooks configured you can skip this
call entirely; it stays the thing you reconcile against.
curl -s "$ITESTHUB/api/v1/orders/ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123" \
-H "Authorization: Bearer $ITESTHUB_KEY"
{
"id": "ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123",
"assessments": [
{
"id": "res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d",
"assessment_code": "verbal-reasoning",
"status": "in_progress",
"started_at": "2026-09-19T14:02:55Z",
"completed_at": null
}
]
}
Call 4
Read the result
Before completion this returns the status and nothing else: a half-finished ability test has no meaningful score, so we do not publish one. Once complete you get the raw score, the standardised scores, the percentile and its band, any component breakdown — and the record of what it was all scored against.
curl -s "$ITESTHUB/api/v1/results/res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d" \
-H "Authorization: Bearer $ITESTHUB_KEY"
{
"id": "res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d",
"order_id": "ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123",
"assessment": {"code": "verbal-reasoning", "name": "Verbal Reasoning", "type": "ability"},
"status": "completed",
"completed_at": "2026-09-19T14:22:07Z",
"norms_provisional": false,
"scored_against": {
"test_version_id": 41,
"test_version_number": 3,
"content_hash": "9f2a7c1e5b8d3a04c6e1f70b2d9a48c35e7f1b6a0c4d8e29f3b5a7c1d0e64f82",
"norm_source": "version",
"norm_group": "General population (N=10,380)",
"form": "Verbal Reasoning - Form 4"
},
"scores": {
"raw": 23,
"percentile": 78,
"band": "High",
"standardised": {"z": 0.77, "t": 57.7, "sten": 7.04, "stanine": 6.54},
"components": [
{"name": "Inference", "score": 9, "max": 10},
{"name": "Evaluation", "score": 8, "max": 10},
{"name": "Interpretation", "score": 6, "max": 10}
],
"scales": []
},
"report_types": [
{"type": "recruitment", "label": "Profile"},
{"type": "candidate", "label": "Candidate"}
]
}
Optional
Take the PDF as well
The report types a given result offers are listed on the result itself as
report_types. Ask for one it does not produce and you get a 404 naming
what is available — never a different report sent quietly in its place.
curl -s "$ITESTHUB/api/v1/results/res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d/report?type=recruitment" \
-H "Authorization: Bearer $ITESTHUB_KEY" -o lovelace-profile.pdf
Test mode
Build the whole integration before a single candidate exists
A sandbox key is ith_test_…. Everything it creates is test data, everything a live key
creates is real, and the two never meet. The half of the API that matters most — the completed
result, the scores, the report, the signed webhook — normally needs a real person to sit a real
assessment to the end. In test mode you ask for it and it is there.
1
Order exactly as you will in production
Same URLs, same bodies, same responses, same scopes, same rate limits, all three ordering modes. Only the key is different.
2
Finish the assessment yourself
One sandbox-only call answers the assessment and finalises it through our own completion path.
outcome is high, typical or low — it moves
the distribution the answers are drawn from rather than fixing a number, so two calls give
different raw scores, percentiles and bands. That matters: a results screen has to render every
band, not the 50th percentile.
curl -s -X POST "$ITESTHUB/api/v1/sandbox/results/res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d/complete" \
-H "Authorization: Bearer $ITESTHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"outcome": "high"}'
It answers with the completed result — the same object
GET /api/v1/results/{id} returns, from the same serialiser, with real
scored_against provenance naming the test version and norm group the answers were
actually scored under. There is one shape of result in this API, not two: an integration built
against a differently shaped sandbox would fail the first time a real candidate finished.
3
Your receiver gets a real signed webhook
Same body, same X-iTestHub-Signature, same retries, same endpoint you registered.
The one difference is the field the envelope has always carried — branch on it if your handler
writes to a production record.
{
"id": "evt_7a1c3e5d9b2f4c6a8e0d1f3b5a7c9e11",
"type": "assessment.completed",
"created_at": "2026-09-19T11:04:12Z",
"livemode": false,
"data": { /* the same result object as above */ }
}
What the sandbox cannot touch
- A test key reaches nothing real — not a candidate, an order, a result, a report. It cannot count them either: the order listing comes back empty and no filter on it confirms that one of your requisition references exists.
- A live key reaches nothing the sandbox made, so your production integration can never report a fabricated score to a hiring manager.
- Sandbox candidates are never emailed, in any of the three ordering modes — including the one where we send the invitation. The address on a test order is one a developer typed into a script, and it may well be somebody's real one.
- Nothing from the sandbox reaches your own screens in iTestHub, our data exports, or the norm groups a real candidate's percentile is calculated against.
Two details worth knowing
- The environment is in the credential. ith_live_7f3a9c2b and ith_test_7f3a9c2b are never two spellings of one key: changing the word in a config file does not repoint your sandbox at production, it stops authenticating.
- A live key calling the completion endpoint is 403 sandbox_only, decided before the id is read — so the answer is the same whether the id is real or nonsense. Completing the same result twice is 409 already_completed: an id you have stored must not start reporting a different score.
- Idempotency keys are per environment, so replaying your test script's keys cannot collide with — or detect — the ones production uses.
Three ways to order
Who holds the candidate's personal data is your decision, not ours
Assessment vendors normally give you one answer: send us the candidate's name and email. iTestHub
gives you three, and they differ in one thing only — the division of responsibility for personal data
and for talking to the candidate. One mode field on the order says which:
identified, identified_with_invitation or anonymous. It is one
field with three values rather than two flags, because the fourth combination — anonymous
and we invite — cannot exist: with no address there is nothing to send to. Omit it and you get
identified, exactly what every order did before the field existed, so nothing an existing
integration already sends can start an email. A mode we do not recognise is a
400, never a fallback.
Identified, we invite
{
"mode": "identified_with_invitation",
"candidate": {
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com"
},
"assessments": ["verbal-reasoning"],
"reference": "REQ-4471"
}
You send the candidate's details and ask us to invite them. It goes out through the same invitation path the iTestHub web app uses — the same email, the same link, the same record the resend and reminder screens read — once per order, never once per assessment, and never twice for a replayed idempotency key. We hold the details as your processor.
Suits employers running iTestHub themselves, and teams who want invitations and reminders handled for them.
Identified, you invite
{
"mode": "identified",
"candidate": {
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com"
},
"assessments": ["verbal-reasoning"],
"reference": "REQ-4471"
}
Details in, link and deadline out, no email from us — and this is the default, so an integration
that sends no mode at all cannot start one by accident. You own every message the
candidate sees, and we still hold their details so our support team can help you find a specific
person.
Suits ATS vendors who own candidate communications end to end.
Anonymous
{
"mode": "anonymous",
"candidate": {
"reference": "CAND-4471-7"
},
"assessments": ["verbal-reasoning"],
"reference": "REQ-4471"
}
Only your own opaque reference: candidate.reference is the one field an anonymous
order's candidate may carry. No name, no email, nothing personal. We return the link, you send it,
and we never hold a single identifying fact about that candidate.
Suits organisations whose DPO will not approve candidate personal data leaving their systems — and public-sector procurement.
Anonymous ordering, and why it changes the conversation
The question that stalls assessment deals is rarely about psychometrics. It is a DPO asking why a third party needs a candidate's name and email at all. With anonymous ordering the answer is that it does not. You send an identifier that means something only inside your systems; we return a link; you send it. The candidate sits the assessment, is scored, produces every report their test supports, appears in exports and fires webhooks — with your reference standing in for a name everywhere a name would otherwise appear.
Because we never hold it, we cannot lose it. Even in the event of a breach here, there is no identifiable personal information about your candidates to expose — there is nothing but your own opaque references and a set of scores.
That claim only holds if the data never arrives in the first place, so we do not quietly discard
it. Personal data in an anonymous order is rejected with a 400 that names the offending
field. Silently dropping it would still mean it had reached our servers and our request
logs, which is precisely what you were avoiding. The rule is an allowlist, not a list of banned
words: on an anonymous order the candidate object may contain reference and nothing
else, so a field name we have never seen is refused exactly as firmly as an email address is. The
error names the field and never quotes the value.
{
"code": "validation_error",
"detail": "The request body is not valid.",
"fields": {
"candidate.email": [
"This is an anonymous order, which must carry no personal data at all. The field was REJECTED, not dropped: nothing from it has been stored. Remove it, or place the order with mode \"identified\" / \"identified_with_invitation\"."
]
}
}
What you give up — say it out loud
In anonymous mode, when something goes wrong, neither of us can ask the other whether a particular named candidate was invited. We do not know who they are. If a candidate calls your team saying they never received a link, we cannot look them up by name, because there is no name to look up.
And what answers it
GET /api/v1/orders lists every order you have placed, filtered by
your own references — reference for the requisition,
candidate_reference for an anonymously ordered candidate — so you never have to
store an iTestHub id to find someone again. You hold the identity; you reconcile.
curl -s "$ITESTHUB/api/v1/orders?reference=REQ-4471&created_after=2026-09-18T00:00:00Z" \
-H "Authorization: Bearer $ITESTHUB_KEY"
{
"data": [
{
"id": "ord_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"mode": "anonymous",
"reference": "REQ-4471",
"created_at": "2026-09-18T10:04:11Z",
"expires_at": "2026-10-18T10:04:11Z",
"candidate": {"reference": "CAND-4471-7"},
"candidate_url": "https://app.itesthub.com/clients/acme/candidate_hub/3d7e0a91-2b4c-4d6e-8f01-2a3b4c5d6e7f/",
"invitation_sent": false,
"link_first_opened_at": null,
"assessments": [
{
"id": "res_7f8e9d0c1b2a3948576a5b4c3d2e1f00",
"assessment_code": "verbal-reasoning",
"status": "pending",
"started_at": null,
"completed_at": null
}
]
}
],
"next_cursor": null,
"has_more": false
}
Three things fall out of that diff. A reference you expected and cannot see was never ordered —
your side failed, retry it. A reference whose link_first_opened_at is
null with every assessment still pending has had a link issued that
nobody has ever opened: when you sent the invitation, that is the signal it did
not arrive. A reference sitting at in_progress for days started and stalled — a
nudge, not a resend.
Precisely: the statuses this API reports are pending, in_progress, completed and expired, and a filter on one matches an order with at least one assessment in that state. Paging is by opaque cursor — follow next_cursor until it is null — so orders placed while you page cannot make a row appear twice or go missing. link_first_opened_at is stamped the first time anybody follows the hub link and never overwritten; it stays null for a candidate administered through the accommodations runner, whose hub we do not serve.
Neither model is the safe one
It is tempting to read anonymous ordering as the secure option and the standard one as the compromise. It is not that. They are two different divisions of responsibility, and each carries work the other does not. Hand us the details and we carry the processing obligations, chase the non-responders, and can answer a question about a named candidate — which is what a small talent team usually wants. Keep them and the obligation to invite, remind, reconcile and audit stays with you — which is what a large ATS vendor, or an organisation with a strict data-minimisation policy, usually wants. Choose on which team should be doing that work, not on which sounds safer.
Webhooks
Stop polling. Get told.
When a candidate finishes, assessment.completed is delivered to your endpoint, signed, so
your ATS updates the moment the result exists rather than the next time your poller happens to look.
{
"id": "evt_6d1f0a94c2b74e0b9a3c8e5d7f210b64",
"type": "assessment.completed",
"created_at": "2026-09-19T14:22:09Z",
"livemode": true,
"data": {
"id": "res_4b2e6a1c9d3f4a5b8c7d6e5f4a3b2c1d",
"order_id": "ord_9c1d4f0b8a7e4d2fa3c5b6e7d8f90123",
"assessment": {"code": "verbal-reasoning", "name": "Verbal Reasoning", "type": "ability"},
"status": "completed",
"completed_at": "2026-09-19T14:22:07Z",
"norms_provisional": false,
"scored_against": {
"test_version_id": 41,
"test_version_number": 3,
"content_hash": "9f2a7c1e5b8d3a04c6e1f70b2d9a48c35e7f1b6a0c4d8e29f3b5a7c1d0e64f82",
"norm_source": "version",
"norm_group": "General population (N=10,380)",
"form": "Verbal Reasoning - Form 4"
},
"scores": {
"raw": 23,
"percentile": 78,
"band": "High",
"standardised": {"z": 0.77, "t": 57.7, "sten": 7.04, "stanine": 6.54},
"components": [
{"name": "Inference", "score": 9, "max": 10},
{"name": "Evaluation", "score": 8, "max": 10},
{"name": "Interpretation", "score": 6, "max": 10}
],
"scales": []
},
"report_types": [
{"type": "recruitment", "label": "Profile"},
{"type": "candidate", "label": "Candidate"}
]
}
}
That data block is not a webhook-shaped summary of a result. It is the result: the same serializer that answers GET /api/v1/results/{id}, so a webhook and a poll of the same id give you one object rather than two that nearly agree. livemode is true for a real completion and false only for a test event. The event id never changes — across every retry and every manual redelivery, the body is byte for byte identical — so deduplicating is a primary-key check. The event id, the event type and the attempt number also arrive as X-iTestHub-Event-Id, X-iTestHub-Event-Type and X-iTestHub-Delivery-Attempt headers, so you can route without parsing.
X-iTestHub-Signature: t=1789399329,v1=5f2c8e1b0a47d93c6e15b8f2a0d47c39e6b1f85a2c7d0e93b4a6c8f1d52e70b3
import hashlib
import hmac
import time
# body is the RAW request bytes, exactly as received:
# do not re-serialise the JSON before checking the signature.
def verify(secret, header, body, tolerance_seconds=300):
parts = dict(part.split("=", 1) for part in header.split(","))
signed = parts["t"].encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256)
if not hmac.compare_digest(expected.hexdigest(), parts["v1"]):
raise ValueError("signature does not match")
if abs(time.time() - int(parts["t"])) > tolerance_seconds:
raise ValueError("timestamp outside the replay window")
return True
HMAC-SHA256 over the timestamp, a full stop and the raw body, as lowercase hex — the same convention Stripe uses, so your team has almost certainly written this function before. Verify over the raw bytes: re-serialising the parsed JSON gives a different string and the signature will not match. Reject a timestamp more than 300 seconds old; five minutes is what iTestHub documents and what its own verifier enforces. That is not a retry deadline — every attempt is signed afresh with its own t, so a delivery retried two hours later still arrives inside tolerance.
Delivery that cannot hurt a candidate
Deliveries run on their own background queue, never inline with a candidate finishing a test. If
your endpoint is down, or slow, or wrong, the candidate still finishes, the result is still scored
and every API call still answers. Answer 2xx as soon as you have stored the event and
do your own work afterwards: we allow 5 seconds to connect and 10 to respond.
Retries you can audit
Six attempts: the first, then five more after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours, each with up to 25% random jitter — just under three hours end to end. Any non-2xx, a timeout, a TLS failure and a refused connection are all retried. Every attempt is recorded — event, endpoint, attempt number, response status, duration, error — so if you ask us what happened to an event there is a record to read, and we can redeliver it by hand.
If your endpoint never recovers, 20 consecutive events that exhaust their attempts switch it off and nothing more is queued for it. Nothing is lost — every event and attempt is still on file — and nothing silently resumes: a person here re-enables it once you say you are ready, and can then redeliver what you missed, with the original ids. A receiver answering 410 Gone is switched off at once, which is what 410 means.
Test it before a real candidate does
Ask us to fire a webhook.test event at your endpoint: the same signature, the same
headers and the same checks a real event gets, with livemode false and no candidate
data in it of any kind. Verify your signature check against that, rather than discovering the
problem the first time somebody finishes an assessment. It works on a disabled endpoint too, which
is precisely when you need it.
What we require of your endpoint, and why
Your endpoint must be HTTPS, with no credentials in the URL. Before every delivery — not only when the endpoint is registered, because DNS changes — we resolve the host and refuse it unless every address it resolves to is publicly routable: loopback, private, link-local and reserved ranges are all refused, in IPv4 and IPv6 alike. Redirects are followed at most three times and every hop is checked again, we read at most 64 KB of your response, and we time out at 5 seconds to connect and 10 to respond. A webhook URL is a request our servers make on your instruction, and a URL pointing inward is the classic way to turn that into a server-side request forgery. We would rather explain the restriction than be the vendor it happened to. A URL we refuse is not retried — no amount of waiting makes it safe — and it counts as a failed event, so a host whose DNS has gone bad is eventually switched off and somebody tells you. Endpoints are registered by iTestHub today, with the signing secret shown once, exactly like a key; unlike an API key we can read it back to you, because both ends need it to compute the same HMAC.
Scoring provenance
A percentile is a comparison. We tell you exactly which one.
A score on its own is a number. "78th percentile" only means something once you know which version of the test was sat, which form, and which norm group the candidate was compared against. Most assessment APIs return the number and leave the rest in a PDF, or in nobody's hands at all.
Every result this API returns carries scored_against: the test version number, the
content hash of that version, the form the candidate actually sat, and the norm group used. The
version is pinned to the candidate's assignment at the moment the order is placed, so somebody who
starts under one version finishes — and is scored — under that one, however often the catalogue
changes underneath them.
That is what makes a result defensible a year later. Norms are refreshed; items are retired and replaced; a test gets a new version. None of that can quietly change what a score you acted on in March meant, because the record of what it was measured against travels with the score itself. If you are ever asked to justify a hiring decision — by a candidate, a regulator or a tribunal — this is the difference between an answer and a shrug.
Where an assessment's norms are still provisional we say so with norms_provisional, and every standardised number comes back null — the raw score stands alone. A percentile derived from an assumed mean asserts a comparison against a group that does not exist, so we do not publish one, here or on the PDF.
"scored_against": {
"test_version_id": 41,
"test_version_number": 3,
"content_hash": "9f2a7c1e5b8d3a04c6e1f70b2d9a48c35e7f1b6a0c4d8e29f3b5a7c1d0e64f82",
"norm_source": "version",
"norm_group": "General population (N=10,380)",
"form": "Verbal Reasoning - Form 4"
}
Keys, isolation, idempotency
Built for a machine you do not control
An API is a credential you hand to somebody else's software, running on somebody else's release schedule, retrying when the network hiccups. Three things follow from that, and all three are enforced in code rather than promised in a contract.
A key is a client, and cannot become another
The client is resolved from the key and from nothing else. There is no URL segment, body field or
header anywhere in this API that names a client, so there is nothing to tamper with. Asking for
another client's order returns the same 404 as an id that never existed — a
403 would confirm the id was real.
A dedicated cross-tenant suite, plus one that reads the view module itself and fails if a view is ever added without the key authentication, the scope check and the rate limit — or if any route in this API ever takes a client identifier.
Keys are scoped, hashed and rotatable
Four scopes — read the catalogue, place and follow orders, read results, download reports — granted separately, because a key that books tests should not thereby see every score your organisation has ever collected. Only a SHA-256 hash is stored, so a stolen backup contains no usable credential.
Rotation issues a second key and puts the first on a clock — both work during the grace window, so you deploy at your own pace. Revocation is immediate, with no cache to wait out.
A retry is not a second candidate
Send Idempotency-Key and a replayed order returns the original, with
Idempotent-Replay: true and nothing created. Reuse the same key with a
different body and you get a 409 rather than a silent overwrite — losing a
candidate without ever seeing a failure is the worst outcome available here.
HTTP/1.1 200 OK
Idempotent-Replay: true
Accessibility
The assessment your candidate sits meets WCAG 2.2 AA
Every page a candidate touches while taking an assessment — the hub, the instructions, each question, the submission step and the confirmation — is built to WCAG 2.2 Level AA, and we verify it the way an auditor would: by running axe-core against the real, live pages and requiring zero violations. It is a standing requirement on every change to those templates, not a one-off exercise that produced a PDF once.
One documented exception, because hiding it would be worse than naming it: a timed assessment auto-submits when its countdown ends, with no pause or extend control. That is WCAG's essential exception — the time limit is part of what an ability test measures, and removing it would invalidate the result against the norms the score is compared to. It is standard practice across the industry, and candidates who need extra time get it as an adjustment instead.
Adjustments that change the sitting, not the score
A candidate with an accommodation profile sits the same assessment with extra time, a hyperlegible typeface, larger text, wider line and letter spacing, or a low-glare or high-contrast scheme. The profile is carried on the candidate and stamped onto the administration record, so what was adjusted is part of the audit trail rather than a note in somebody's inbox.
What adjusts is the chrome and the text around the item. The item itself is never re-rendered: changing a stimulus's contrast or polarity changes what it measures, and would invalidate the comparison against the norms the candidate's score is reported against.
This is the part of a public-sector tender most assessment vendors answer with a statement of intent. It is worth asking for evidence — of us as well.
Response validity
The score you receive should be the candidate's
Behavioural signals, on-screen protections and ongoing statistical work flag sessions inconsistent with unaided human performance — and flag them for your review, with the evidence, rather than rejecting anyone automatically. It is the same detection whether a candidate arrived through this API or through the web app.
What it takes
An honest estimate of the work
We will not claim ten minutes. Here is the actual list, and you can judge it against your own backlog.
What you build
- Store two ids per assessment — the order id and the result id — or store neither, and find your orders again by the reference you already hold.
- One POST when an applicant reaches your testing stage, with an idempotency key you already have — your own application id will do.
- Put a link in an email you already send.
- One endpoint that verifies an HMAC signature and enqueues the event.
- Map four statuses onto your own pipeline, and decide what your recruiters see.
In our experience the long pole is your own release and security review process, not this API.
What you do not have to build
- Anything that scores a test, applies a norm or renders a report.
- A candidate-facing assessment experience, on any device, to WCAG 2.2 AA.
- Reconciliation of retries — idempotency is ours to get right.
- A mapping from our ids back to your candidates: the order listing is filtered by your own references, so your ids stay the join key.
- A second representation of a result for your webhook handler to parse.
Conventions worth knowing before you start
- Timestamps are ISO-8601, UTC, Z-suffixed. Ids are opaque strings — ord_… and res_… — that are not database keys and are not sequential. Store them; do not parse them. So is a listing cursor: it is ours to issue and yours to hand back, not something to construct.
- Rate limits are per key rather than per client, so two of your integrations never starve each other. Over the limit is 429 with a Retry-After header in seconds.
- Reading a result or downloading a report writes a row to our candidate-data access log naming your key's public id. Who looked at a candidate's data has an answer for API reads exactly as it does for a signed-in user.
Errors are always {"code", "detail"}, plus fields when a body failed
validation. Branch on code; show detail to a human. Every code, and every
field of every endpoint, is in the
API reference — and in the OpenAPI document
it is rendered from, at /api/v1/openapi.yaml.
{
"code": "unknown_assessment",
"detail": "'telepathy' is not an assessment you can order. GET /api/v1/assessments lists the codes available.",
"fields": {"assessments": ["telepathy"]}
}
What this API does not do yet
We would rather you found this out here than three sprints in. There is no SDK or typed client
library we maintain — it is five HTTP endpoints, and your language's HTTP client is the integration,
though the published OpenAPI document will generate
you one. There is no self-serve
key issuance: we issue keys — live and sandbox — and webhook endpoints are registered by us too.
There is no billing or unit-consumption endpoint. Results are available for
candidates ordered through this API; a candidate an admin added in the web app has no id here, and
their results live in the web app. There is no order.expired webhook either, and
deliberately so: expiry here is not something that happens, it is a comparison against the deadline
you set, made when you read the order.
Get a key and an engineer to talk to
Tell us which client the key is for, what the integration is, and which of the four scopes you need. We issue it, show it to you once, and stay on the thread while you build — including a test event fired at your endpoint before a real candidate ever reaches one.