Read this before you build
The few things to understand, not implement, so the integration is predictable.
Authentication
- A signed-in person. They use their own Thomas session and see what their own role allows. Your integration is never this caller.
- Your API client, acting as itself. Your backend calls Thomas server to server with an OAuth client credential. It reaches what your Grant approves in that tenant.
- Your API client, acting on behalf of a named person. The same credential, plus a Thomas-On-Behalf-Of header naming the person. Thomas applies that person’s permissions and role within the scopes on your Grant.
Machine access is available today, as itself and on behalf of a person. You choose per call. The API reference shows Thomas-On-Behalf-Of as required on an operation that accepts only calls on behalf of a person. An operation that accepts your client both as itself and on behalf of a person marks the header optional. There is no end-user OAuth flow to build. Questionnaires still require the person to sign in to Thomas.
Exchange the client credential at POST /oauth/token for a bearer token valid for one hour. Send that token as a bearer token on every request; there is no refresh token, so request a new one when it expires.
A token request gives up after 10 seconds. If it fails, a token you already hold keeps working until it expires, so retry later.
curl https://api.thomas.co/v1/assessments/profile/ind_7Qk2 \ -H "Authorization: Bearer $THOMAS_TOKEN" \ -H "Thomas-Tenant-Id: $THOMAS_TENANT_ID"
Your token carries identity only. Each operation lists the scope your Grant must hold on the Tenant you address. Scopes are checked against live state on every request, so a change on the Thomas side takes effect on your next call. No credential grants bulk access to raw scoring or the item bank.
401 Unauthorized
{
"statusCode": 401,
"message": "Invalid or missing authentication token",
"error": "AUTH_TOKEN_INVALID"
}Validation errors
A request that fails validation returns 400 with "error": "HTTP_BAD_REQUEST", "message": "Validation failed" and a details.validationIssues list. Each entry names one problem as a path and a rule:
400 Bad Request
{
"statusCode": 400,
"message": "Validation failed",
"error": "HTTP_BAD_REQUEST",
"details": { "validationIssues": [{ "path": "contact.email", "rule": "invalid_format" }] }
}path is the dot-joined field path, and $ is the request itself. rule is the rule the field failed. The response never repeats the values you sent. A refusal with its own code, such as ASSIGNMENT_RETURN_URL_NOT_ALLOWED, keeps that code in error, so check error first.
Choosing a tenant
Each customer is a tenant. Most calls act inside one, so send exactly one Thomas-Tenant-Id header naming it.
The header only picks the tenant. Your Grant for that customer decides access, and it is checked on every call.
Calls above a tenant refuse the header: listing, creating, withdrawing or recovering tenants, diagnostics, and webhook subscriptions. The API reference marks which calls take it.
A tenant’s ID comes back as tenantId when you create it. For a tenant you already hold a Grant on, list your tenants, as the end of this section shows. For an existing customer you hold no Grant on yet, Thomas gives you its tenant ID. Calls on behalf of a person send the header too.
Each Grant also records your role in that tenant. source-of-record means your system is the customer’s authoritative source for its people and teams. consumer means not the source of record, and nothing more. It does not make your integration read-only: what you may write is set by the write scopes your Grant carries.
When the Thomas-Tenant-Id header, your Grant for that tenant, or your role in it does not allow the call, Thomas refuses it:
- 400 AUTH_TENANT_CONTEXT_INVALID
- The Thomas-Tenant-Id header is missing, repeated, invalid, or sent to a call that refuses it. The message names Thomas-Tenant-Id.
- 403 AUTH_TENANT_NOT_GRANTED
- No active Grant for this tenant and call. It does not reveal whether the tenant exists.
- 403 AUTH_SOURCE_OF_RECORD_REQUIRED
- Your Grant is active and carries the scope, but your integration is not the tenant's source of record for the people or teams it is writing on its own. A consumer-role Grant always gets this code; a source-of-record integration gets it if the claim has moved to another integration. Act on behalf of a person instead.
- 403 AUTH_SERVICE_MEMBERSHIP_MISSING
- Your Grant is active, but your integration isn't set up in the tenant yet. Contact Thomas.
- 403 AUTH_TENANT_SUSPENDED
- The tenant is suspended.
To find the tenants you hold a Grant on, call GET /v1/tenants. It takes no tenant header. The list is paged: follow nextCursor until it is null. Each row carries the tenantId to send, the tenant’s name, and your Grant’s status, partnerRole and scopes. Tenant calls require an active Grant. A pending or inactive Grant is listed but is not callable. A tenant you withdrew can be recovered within 30 days.
Acting on behalf of a person
Your API client can act on behalf of one named person. This is available now. Thomas checks your credential, your Grant, and that the person is a current member of the tenant. It then applies that person’s permissions and role within the scopes on your Grant. Some operations accept only this caller. Coaching and sharing are two of them. Others accept your API client as itself too; their API reference marks Thomas-On-Behalf-Of optional.
Authorization: Bearer $THOMAS_TOKEN Thomas-Tenant-Id: $THOMAS_TENANT_ID Thomas-On-Behalf-Of: thomas:<companyIndividualId>
The prefix is required, and Thomas never guesses it.
thomas:<companyIndividualId>is the person’s Thomas id in this tenant, a UUID. It comes back as the id of the person’s membership when you enrol them.partner:<partnerReference>is your own reference for the person, the one you registered when you enrolled them in this tenant with createColleague.
Your Grant must hold the scopes the operation lists. It is checked on every call, as it is when you act as yourself.
- 400 AUTH_TENANT_CONTEXT_INVALID
- The header is missing its prefix, malformed or repeated, or your credential cannot act on behalf of a person. The message reads "Exactly one valid Thomas-On-Behalf-Of header is required".
- 404 AUTH_ON_BEHALF_PERSON_NOT_AVAILABLE
- The named person cannot be resolved to one current member of this tenant. It does not say why.
- 403 PRINCIPAL_TYPE_NOT_ALLOWED
- The operation does not accept this caller. You sent the header to an operation that refuses it, or called an on-behalf-only operation without it.
When Thomas audits an action you take on behalf of a person, the record names the person and shows it came via your integration.
When you act on behalf of a person, you act as that person. Your agreement with Thomas binds you to two rules:
- Show person-only data only to that person.
- Act on their behalf only while you are serving them.
Thomas cannot check that the person is at the keyboard, and it does not ask them to approve each call. Keeping these rules is your responsibility.
ThomAI coaching is available on behalf of a person. A coaching conversation always belongs to the person you name, as if they had opened it in Thomas. Your API client cannot coach as itself. See the Coach in context recipe.
Some steps only the person can take in Thomas, such as completing a questionnaire. They sign in to Thomas to do it. When they finish an assessment, Thomas can hand them back to your product.
- Ask Thomas to register your return origins: exact HTTPS origins set on your API client, with no wildcards and no paths.
- Pass
returnUrlwhen you create the assignment: an absolute HTTPS URL under one of those origins, at most 2048 characters, with no credentials and no fragment.createAssessmentAssignment,bulkCreateAssessmentAssignmentsandbulkAssignCandidateAssessmentsaccept it, as your API client or on behalf of a person. Any other URL is refused with400 ASSIGNMENT_RETURN_URL_NOT_ALLOWED, and nothing is created. - The first return address stays: asking again for an assessment that is already pending does not apply a new
returnUrl. - At the end, the person sees “Back to” your partner name as the main action. It opens exactly your URL and carries nothing. If your origin is no longer registered, your API client or partner is not active, or your API client no longer has an active Grant for the tenant the assessment belongs to, they get the normal Thomas completion screen instead.
Read the outcome through the API, never from the return. A check-in has no hand-back. It ends on the person’s results page in Thomas. Open its landing page in a new tab: https://app.thomas.co/me/reflections/check-in-landing/<runId>, where runId comes from requestConnectionCheckIn.
Identity & reconciliation
Email is the identity key. Send a person's email and we resolve them to one Thomas identity, the same person across every organisation. Matching is exact and case-insensitive. Because this is psychometric data, we never guess with fuzzy or probabilistic matches.
Global and exact. One email, one identity, even across organisations.
Scoped to your company. The same HRIS id at another company is a different person.
Acting as itself, your API client gets 404 IDENTITY_INDIVIDUAL_NOT_FOUND when nobody matches: Resolve identity never creates anyone. To add a person, create them as an employee. Acting on behalf of a person, a lookup that matches nobody creates a new identity from its email, as it would for that person signed in. If email and HRIS id point at different people, we flag it for review, never a silent merge.
partner reference — Thomas matches it within your connection, so no personal data needs to leave your system. (Resolve identity itself looks people up by email or HRIS id.){ "email": "alex@acme.com" }
→ { "individualId": "ind_7Qk2", "created": false }
# Or resolve without sending an email:
{ "hrisId": "EMP-12345" }
→ { "individualId": "ind_7Qk2", "created": false }One connection per application
A connection — your API client — is one application of yours at one customer. The references it sends belong to that connection: each employee’s partnerReference (POST /v1/colleagues) and each team’s externalTeamReference (POST /v1/teams). So does the order of employee updates, set by sourceVersion. Team writes are not version-ordered: a team’s sourceVersion is recorded, never compared.
Another connection can’t see them, even your own on the same customer. Look up an employee by a reference another connection sent (GET /v1/colleagues/{id}) and you get a 404.
Run every instance and region of one application through one connection, sharing its credentials. Rotating a credential doesn’t change the connection: its references and update order carry on. Create a second connection only for a genuinely separate application.
How Thomas works
Sandbox results: what they prove, and what they do not
A sandbox can be populated — filled with invented people who read as though they have completed their assessments and run a team check-in. It exists so you can build and test against a realistic tenant without waiting for real people to sit anything.
A populated result is not proof that scoring works.
Nobody answered anything. The rows are written directly; no submission is scored, no scoring job runs, and no answers are stored for an invented person. Nothing about a populated profile tells you a real candidate would receive that profile, and no number in it should be read as a psychometric claim.
Identifiers differ between production and sandbox.
A customer’s sandbox does not share ids with its production tenant. The tenant ID, each person’s Thomas id and each team id are different. Your partner references are registered per tenant too. Resolve people in each one separately, and never send an id from one to the other.
What it does prove
- The API shape. Every read you build against returns the same fields, in the same shape, as it will for a real completion.
- The completion fact. A populated completion records the same internal completion fact, for the same person and with the same fields, as a genuinely scored one. What your integration will eventually be handed is already the real shape.
Outbound webhook delivery is not available yet.
Thomas does not currently send completion webhooks to a partner endpoint — not for a populated completion and not for a real one. Until it does, a populated sandbox lets you build and check the read side; it cannot deliver an event to your server, because nothing can.
What it deliberately leaves alone
- Individual check-in answers and scores. A populated sandbox closes a team check-in with a team-level outcome and invents nobody’s personal answers, so the organisation’s check-in participation figure reads low until somebody completes one for real. That is by design: individual reflections are the most personal thing in the platform and a demo never fabricates them. The populated check-in itself reads as taken by its whole team: its participation is N of N, and it is not flagged as short of responses.
- The real journey. Signing in as an invented person and completing a check-in for real produces your data exactly as production would — scored by the real engine, with nothing in the sandbox short-circuiting it.
Everything a sandbox holds is invented and disposable. Nothing in it is a real person, and nothing in it leaves the sandbox.