Getting started
Every request is authenticated with a company API key. A key identifies exactly one organisation, and every endpoint returns only that organisation's data — with two legacy exceptions noted where they apply.
Base URLs
Base URL | Notes |
| Current API. Use this for new integrations. |
| Legacy. Two endpoints only, with an older response format. |
Authentication
Send your key in the THM-API-KEY header on every request. Keys are issued per organisation from your management dashboard.
curl -H "THM-API-KEY: your-key-here" \
https://tryhackme.com/api/v2/external/users
A missing, unrecognised or deactivated key returns 403. Deactivating a key in the dashboard takes effect immediately across both base URLs.
Response format
Endpoints under /api/v2/external wrap their payload in a status envelope. Legacy endpoints return their object directly, with no wrapper — worth handling explicitly if you call both.
// /api/v2/external/*
{ "status": "success", "data": { … } }
// /external/api/*
{ "roomInfo": [ … ] }
Errors
Status | Meaning |
| A parameter is missing or malformed. The message names the parameter. |
| API key missing, invalid, expired, or the learner you asked for is not in your organisation. |
| The room, learner or assignment does not exist. |
| Rate-limit budget exceeded. Wait before retrying; see Rate limits. |
Pagination
Paginated endpoints take page (1-based) and return page, total and totalPages alongside the results. Page size is fixed at 10 on the v2 endpoints, except Completed rooms, which accepts its own limit. Legacy endpoints are not paginated.
Rate limits
Rate-limit budgets are keyed by a one-way digest of the company API key. Requests using the same key share a budget across source IPs; different API keys have independent budgets. Each endpoint has an independent budget unless explicitly documented otherwise. Categories below group endpoints for readability; they do not share a category-wide budget.
Paths below use /api/v2/external unless they start with /external/api.
Category | Endpoints | Initial limit |
Cheap reads |
| 1,000/hour |
Per-learner activity |
| 1,000/hour |
Assignment computation |
| 1,000/hour |
Large room scoreboard |
| 300/hour |
Expensive report |
| 100/hour |
Seat writes |
| 100/hour |
Legacy bulk reads |
| 100/hour |
Completed rooms |
| 1,000/hour |
Rate-limit headers
Header | Meaning |
| Maximum requests allowed in the current endpoint's window. |
| Requests remaining in that window. |
| Seconds until the window resets. |
| Request budget and window duration; for example, |
| Seconds to wait before retrying after a |
When you receive 429, wait for the duration in Retry-After, or the reset duration in RateLimit-Reset, before retrying.
Response 429
{
"message": "Too many requests"
}All endpoints
Method | Path | Returns |
GET |
| Your seated learners |
GET |
| One learner's profile |
GET |
| One learner's room activity |
GET |
| Public rooms a learner finished |
GET |
| Your assignments |
GET |
| Per-learner assignment results |
GET |
| Your learners' scores in a room |
PUT |
| Assign a learner to a seat |
DELETE |
| Free a learner's seat |
POST |
| Time spent per learner |
GET |
| Your learners (legacy) |
GET |
| Public + your admins' rooms (legacy) |
Users
GET List learners
/api/v2/external/users
Returns every learner occupying a seat in your organisation, ten per page. Learners who have left, or who never claimed a seat, are not included.
Query parameters
Name | Type | Description |
|
| Page number, 1-based. Defaults to |
|
| Filters by username or email. Case-insensitive, max 100 characters. |
|
|
|
|
|
|
Response 200
Field | Type | Description |
|
| One entry per seated learner. |
|
| Stable identifier. Use this for the other user endpoints. |
|
| Public handle on the platform. |
|
| Address the seat was assigned to. |
|
| Real name, where the learner has set one. |
|
| URL of the profile image. |
|
| When the account was created, not when the seat was assigned. |
|
| Groups the learner belongs to, as |
|
| Lifetime points earned. |
|
| Points earned this calendar month. |
|
| Most recent activity on the platform. |
|
| Learners matching the query across all pages. |
|
| Page you received. |
|
| Pages available at the current filter. |
|
|
|
Example
curl -H "THM-API-KEY: $KEY" \
"https://tryhackme.com/api/v2/external/users?page=1&sortBy=email"
{
"status": "success",
"data": {
"users": [
{
"userId": "6501f2a4c9e1b83d7a4f1120",
"username": "ada.byron",
"email": "[email protected]",
"fullName": "Ada Byron",
"avatar": "https://tryhackme-images.s3.amazonaws.com/user-avatars/…",
"userDateSignUp": "2025-11-04T09:12:33.000Z",
"groups": [ { "groupId": "6501f2a4c9e1b83d7a4f0001", "title": "SOC Team" } ],
"totalPoints": 18420,
"monthlyPoints": 940,
"lastAction": "2026-09-14T17:41:02.000Z"
}
],
"total": 34,
"page": 1,
"totalPages": 4,
"privacy": { "individualDataVisible": true, "memberNamesVisible": true }
}
}When individualDataVisible is false, protected activity fields remain present with a null value. Treat null as hidden or unavailable, not as zero activity. Identity and structural fields remain populated. memberNamesVisible indicates whether fullName is available. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
GET Get a learner
/api/v2/external/users/:userId
Returns the profile of one learner in your organisation, including earned badges. Requesting a learner outside your organisation returns 403, not 404.
Path parameters
Name | Type | Description |
|
| 24-character hex id, as returned by List learners. Not the username. |
Response 200
Field | Type | Description |
|
| Stable identifier. |
|
| Public handle. |
|
| Address the seat was assigned to. |
|
| Real name, where set. |
|
| URL of the profile image. |
|
| When the account was created. |
|
| Groups the learner belongs to. |
|
| Earned badges, as |
|
| Lifetime points earned. |
|
| Most recent activity on the platform. |
|
| Visibility metadata for this response. |
|
| Whether individual achievement, points, and activity values are available. |
|
| Whether |
Under company-level GDPR, protected fullName, totalPoints, and lastAction values remain present as null; badges remains present as [] when achievements are hidden. Identity and structural fields remain populated. Use privacy.individualDataVisible to distinguish hidden achievements from a learner who has earned no badges. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Errors
Status | Meaning |
| userId is not a valid ObjectId. |
| The learner is not a member of your organisation. |
| No such learner. |
Example
curl -H "THM-API-KEY: $KEY" \
https://tryhackme.com/api/v2/external/users/6501f2a4c9e1b83d7a4f1120
GET Get a learner's room activity
/api/v2/external/users/:userId/rooms
Returns one learner's progress across every room they have joined, with lifetime activity totals and their ten most recent actions. Room progress is paginated ten per page; the totals and recent actions are not.
Path parameters
Name | Type | Description |
|
| 24-character hex id. |
Query parameters
Name | Type | Description |
|
| Page of |
|
| Filters rooms by name. Max 100 characters. |
|
|
|
|
|
|
Response 200
Field | Type | Description |
|
| Stable identifier. |
|
| Public handle. |
|
| URL of the profile image. |
|
| When the account was created. |
|
| Rooms entered, lifetime. |
|
| Rooms finished, lifetime. |
|
| Answers submitted, lifetime. |
|
| Correct answers, lifetime. |
|
| Virtual machines started, lifetime. |
|
| Ten most recent actions: |
|
| This page of rooms. |
|
| Room title. |
|
| Room's URL slug. |
|
| Whether every task is finished. |
|
| First entry into the room. |
|
| When it was first completed, or |
|
| Completions, counting resets. |
|
| Rooms across all pages. |
|
| Page you received. |
|
| Pages available. |
|
| Visibility metadata for this response. |
|
| Whether the learner's room activity is available. |
When privacy.individualDataVisible is false, identity fields remain available, activity totals and pagination totals are null, and recentActions and roomProgress are empty arrays. These values mean the data is hidden; they do not mean the learner has no activity. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Example
curl -H "THM-API-KEY: $KEY" \
"https://tryhackme.com/api/v2/external/users/6501f2a4c9e1b83d7a4f1120/rooms?page=1"
Company-level GDPR response
{
"status": "success",
"data": {
"userId": "6501f2a4c9e1b83d7a4f1120",
"username": "ada.byron",
"avatar": "https://example.com/avatar.png",
"userDateSignUp": "2025-11-04T09:12:33.000Z",
"roomsJoined": null,
"roomsCompleted": null,
"questionsAnswered": null,
"questionsAnsweredCorrectly": null,
"vmsDeployed": null,
"recentActions": [],
"roomProgress": [],
"totalRoomProgress": null,
"page": 1,
"totalPages": null,
"privacy": {
"individualDataVisible": false
}
}
}GET List a learner's completed rooms
/api/v2/external/users/:username/completed-rooms
Returns the public rooms a learner has completed, newest first. Private and organisation-only rooms are excluded, so this is narrower than the roomsCompleted count from room activity. Addressed by username, not id.
Path parameters
Name | Type | Description |
|
| The learner's public handle. |
Query parameters
Name | Type | Description |
|
| Page number, 1-based. Defaults to |
|
| Rooms per page, max |
Response 200
Field | Type | Description |
|
| Completed rooms on this page. |
|
| Completed public rooms in total. |
|
| Page size applied. |
|
| Page you received. |
|
| Pages available. |
|
| Whether a further page exists. |
|
| Whether an earlier page exists. |
|
| Next page number, or |
|
| Previous page number, or |
|
| Index of the first row on this page. |
|
| Visibility metadata for this response. |
|
| Whether the learner's completed-room history is available. |
When privacy.individualDataVisible is false, docs is empty because completed-room history is hidden. It does not necessarily mean that the learner has completed no rooms. Company-level GDPR keeps the response shape stable. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Errors
Status | Meaning |
| The learner is not a member of your organisation. |
| No such learner, or the account is suspended. |
Example
curl -H "THM-API-KEY: $KEY" \
"https://tryhackme.com/api/v2/external/users/ada.byron/completed-rooms?limit=50"
Company-level GDPR response
{
"status": "success",
"data": {
"docs": [],
"totalDocs": null,
"limit": 16,
"page": 1,
"totalPages": null,
"nextPage": null,
"prevPage": null,
"pagingCounter": null,
"hasPrevPage": false,
"hasNextPage": false,
"privacy": {
"individualDataVisible": false
}
}
}Assignments
GET List assignments
/api/v2/external/assignments
Returns every assignment created by your organisation, ten per page, with the number of learners assigned to each.
Query parameters
Name | Type | Description |
|
| Page number, 1-based. Defaults to |
|
| Filters by title. Case-insensitive, max 100 characters. |
|
| One of |
|
|
|
|
|
|
|
| Only assignments created on or after this date. |
|
| Only assignments created on or before this date. Must not precede |
Response 200
Field | Type | Description |
|
| Assignments on this page. |
|
| Use this with the scoreboard endpoint below. |
|
| Name given when the assignment was created. |
|
| Determines the shape of the scoreboard rows. |
|
| When the assignment was set. |
|
| Whether a deadline is in force. |
|
| The deadline, when one is set. |
|
| Learners assigned, counting group membership. |
|
| Assignments matching the query. |
|
| Page you received. |
|
| Pages available. |
GDPR Compliance Mode does not change this response because it contains assignment metadata and aggregate assignee counts only. It does not include individual learner activity, progress, scores, or performance. Individual learner results are protected separately by the assignment scoreboard endpoint.
Example
curl -H "THM-API-KEY: $KEY" \
"https://tryhackme.com/api/v2/external/assignments?assignmentType=room&sortBy=deadline"
GET Get an assignment scoreboard
/api/v2/external/assignments/:assignmentId/scoreboard
Returns per-learner results for one assignment, ten per page, covering everyone it was assigned to — directly or through a group. The shape of each row depends on the assignment type; check assignmentType before reading the rows.
Path parameters
Name | Type | Description |
|
| The |
Query parameters
Name | Type | Description |
|
| Page number, 1-based. Defaults to |
|
| Filters learners by name or email. Max 100 characters. |
Response 200
Field | Type | Description |
|
| The assignment itself: |
|
| Repeated at the top level so you can branch before parsing rows. |
|
| Learner rows — see the field sets below. |
|
| Learners assigned in total. |
|
| Page you received. |
|
| Pages available. |
|
|
|
privacy.individualDataVisible controls per-learner result fields. Assignment metadata, learner identity, hasSeat, pagination, and totalQuestions remain available. Protected result fields remain present with null values under company-level GDPR. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Row fields — always present
Field | Type | Description |
|
| The learner's identifier. |
|
| Public handle. |
|
| Address on the seat. |
|
| Real name, where set. |
|
| URL of the profile image. |
|
| Whether they still hold a seat. Past members appear with |
|
| Points earned on this assignment. |
|
| Time spent, in seconds. |
|
| Completion time, or |
|
| Submission state, for example |
Row fields — by assignment type
Type | Additional fields |
|
|
|
|
|
|
Nullable result fields by assignment type
Assignment type | Fields that remain present as |
|
|
|
|
|
|
Completion percentage — room, module, and path rows
completionPercentage is a number | null on a scale of 0 to 100. It is calculated as (completedQuestions / totalQuestions) * 100, using the same deadline-aware completedQuestions value returned in the row. When totalQuestions is zero, the result is 0.
The API follows the management dashboard's whole-number convention: round to the nearest integer, cap an incomplete learner at 99, and return 100 only for genuine completion. When privacy.individualDataVisible is false, the field remains present as null.
completedQuestions, totalQuestions, and totalAttempts remain in the response for backwards compatibility. completedQuestions and totalAttempts are nullable under GDPR; totalQuestions remains available.
Example
curl -H "THM-API-KEY: $KEY" \
https://tryhackme.com/api/v2/external/assignments/6501f2a4c9e1b83d7a4f2200/scoreboard
{
"status": "success",
"data": {
"assignment": {
"id": "6501f2a4c9e1b83d7a4f2200",
"title": "Week 3 — Network Fundamentals",
"assignmentType": "room",
"created": "2026-08-31T08:00:00.000Z",
"deadlineToggled": true,
"deadlineDate": "2026-09-07T23:59:59.000Z"
},
"assignmentType": "room",
"users": [
{
"id": "6501f2a4c9e1b83d7a4f1120",
"username": "ada.byron",
"email": "[email protected]",
"fullName": "Ada Byron",
"avatar": "https://tryhackme-images.s3.amazonaws.com/user-avatars/…",
"hasSeat": true,
"completedQuestions": 12,
"totalQuestions": 14,
"completionPercentage": 86,
"totalAttempts": 19,
"pointsAwarded": 280,
"timeSpentSeconds": 4310,
"completedAt": null,
"assignmentStatus": "not-submitted"
}
],
"total": 22,
"page": 1,
"totalPages": 3,
"privacy": { "individualDataVisible": true, "memberNamesVisible": true }
}
}Scoreboards
GET Get a room scoreboard
/api/v2/external/scoreboard?roomCode={code}
Returns the scoreboard for one room, filtered to learners in your organisation — not the platform-wide leaderboard. Learners who joined but scored nothing are included, so you can tell "not started" from "not assigned". Capped at 500 rows and not paginated.
Query parameters
Name | Type | Description |
|
| Required. The room's URL slug, for example |
Response 200
Field | Type | Description |
|
| Public handle. |
|
| URL of the profile image. |
|
| The learner's platform level. |
|
| Points scored in this room. |
|
| Position within this scoreboard, not platform-wide. |
|
| Per-task completion, keyed by task number. |
|
| When the score was last updated. |
|
| The learner's identifier. |
When company-level GDPR Compliance Mode hides individual data, this endpoint returns an empty scoreboard because leaderboard membership, ranking, scores, task progress, and scoring times are individual performance data. An empty array in this state does not mean that no learners participated. Group-level GDPR does not restrict company API-key access.
Errors
Status | Meaning |
|
|
| No room with that code, or it has been removed. |
Example
curl -H "THM-API-KEY: $KEY" \
"https://tryhackme.com/api/v2/external/scoreboard?roomCode=networkingessentials"
Company-level GDPR response
{
"status": "success",
"data": []
}Seats
PUT Assign a learner to a seat
/api/v2/external/seats/users
Places an existing TryHackMe account into the first free seat in your organisation. The account must already exist — this endpoint does not create one.
Body
Field | Type | Description |
|
| Required. Email of the existing account to seat. |
Response 200
Field | Type | Description |
|
| Handle of the learner now occupying the seat. |
Errors
Status | Meaning |
|
|
|
|
Example
curl -X PUT -H "THM-API-KEY: $KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
https://tryhackme.com/api/v2/external/seats/users
{ "status": "success", "data": { "username": "ada.byron" } }DELETE Free a learner's seat
/api/v2/external/seats/users
Removes a learner from their seat in your organisation and from its subscription. The TryHackMe account itself is not deleted, and the learner keeps their progress.
Body
Field | Type | Description |
|
| Required. Email of the seated learner to remove. |
Response 200
Field | Type | Description |
|
| Handle of the learner removed. |
Errors
Status | Meaning |
|
|
|
|
Example
curl -X DELETE -H "THM-API-KEY: $KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
https://tryhackme.com/api/v2/external/seats/users
Reports
POST Time spent report
/api/v2/external/reports/time
Returns total time spent on rooms, per learner, over a date range you choose. Every address in userEmails must belong to your organisation — if any does not, the whole request fails and the response names the offending addresses.
The report includes only the requested learners, room types, and date range. Every email must belong to the API-key company. minSeconds and maxSeconds describe the returned learner rows, not the whole company.
Body
Field | Type | Description |
|
| Required. At least one address. All must be learners in your organisation. |
|
| Required. Any of |
|
| Required. Start of the range, for example |
|
| Required. End of the range. |
Response 200
Field | Type | Description |
|
| One entry per learner with recorded time, sorted by email. |
|
| The learner's identifier. |
|
| Public handle. |
|
| Address you asked for. |
|
| Real name, or an empty string. |
|
| Time spent across the selected room types. |
|
| Lowest |
|
| Highest |
|
| Visibility metadata for this response. |
|
| Whether individual time-report data is available. |
When privacy.individualDataVisible is false, users is empty and minSeconds and maxSeconds are null. This means the report is hidden, not necessarily that none of the requested learners recorded time. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Errors
Status | Meaning |
| A field is missing, or a date is not valid ISO 8601. |
|
|
Example
curl -X POST -H "THM-API-KEY: $KEY" \
-H "Content-Type: application/json" \
-d '{
"userEmails": ["[email protected]", "[email protected]"],
"roomTypes": ["challenge", "walkthrough"],
"from": "2026-08-01T00:00:00.000Z",
"to": "2026-09-01T00:00:00.000Z"
}' \
https://tryhackme.com/api/v2/external/reports/time
{
"status": "success",
"data": {
"minSeconds": 18300,
"maxSeconds": 18300,
"users": [
{
"_id": "6501f2a4c9e1b83d7a4f1120",
"username": "ada.byron",
"email": "[email protected]",
"fullName": "Ada Byron",
"totalSeconds": 18300
}
],
"privacy": {
"individualDataVisible": true
}
}
}When privacy.individualDataVisible is true, learners with no recorded time in the range are omitted from users rather than returned with zero. When nothing matches at all, users is empty and both bounds are 0.
Company-level GDPR response
{
"status": "success",
"data": {
"minSeconds": null,
"maxSeconds": null,
"users": [],
"privacy": {
"individualDataVisible": false
}
}
}Legacy endpoints
GET Retrieve users
/external/api/users
Returns every learner on your organisation's seats and API-user list, in one response. Not paginated, so the payload grows with your organisation. List learners is the paginated equivalent.
Request
No query parameters. This legacy endpoint returns all learners in the company's seat and API-user lists and is not paginated.
Response 200
Field | Type | Description |
|
| Always |
|
| All learners, unpaginated. |
|
| Public handle. |
|
| Address on the seat. |
|
| URL of the profile image. |
|
| When the account was created. |
|
| Lifetime points. Note the singular field name. |
|
| Points earned this calendar month. |
|
| Your own external id for the learner, where one was registered. |
|
| Visibility metadata for this response. |
|
| Whether individual points are available. |
Under company-level GDPR, learner identity remains visible while totalPoint and monthlyPoints remain present as null. Group-level GDPR does not hide data from API-key requests because the API key represents company-admin access.
Example
curl -H "THM-API-KEY: $KEY" https://tryhackme.com/external/api/users
{
"success": true,
"users": [
{
"username": "ada.byron",
"email": "[email protected]",
"avatar": "https://example.com/avatar.png",
"dateSignedUp": "2025-11-04T09:12:33.000Z",
"totalPoint": 18420,
"monthlyPoints": 940,
"extUserId": "EMP-4471"
}
],
"privacy": {
"individualDataVisible": true
}
}Company-level GDPR response
{
"success": true,
"users": [
{
"username": "ada.byron",
"email": "[email protected]",
"avatar": "https://example.com/avatar.png",
"dateSignedUp": "2025-11-04T09:12:33.000Z",
"totalPoint": null,
"monthlyPoints": null,
"extUserId": "EMP-4471"
}
],
"privacy": {
"individualDataVisible": false
}
}GET Retrieve rooms
/external/api/rooms
Returns every public room on the TryHackMe platform, plus every room created by your organisation's admins — not only your organisation's rooms. Expect well over a thousand entries. Not paginated.
Response 200
Field | Type | Description |
|
| All matching rooms, unpaginated. |
|
| Room's URL slug. Pass this as |
|
| Display name. |
|
| Summary shown on the room page. |
|
|
|
Example
curl -H "THM-API-KEY: $KEY" https://tryhackme.com/external/api/rooms
{
"roomInfo": [
{
"code": "networkingessentials",
"title": "Networking Essentials",
"description": "Learn about the essential protocols that make networks work.",
"public": true
}
]
}Filter on public to separate the platform catalogue from your organisation's own rooms.
Questions about your API key or access? Contact your TryHackMe account manager.