Skills
Available. Job roles, a person’s job roles, a person’s skills and the team skills matrix are readable (and job roles writable) through the API. These endpoints follow your plan: job roles and a person’s skills need Skills management (Starter and up); the matrix needs Skills planning (Growth and up) and your organization’s skills-planning switch turned on. An API key alone is not enough: a plan without the feature answers
403.
A job role (for example Site supervisor) lists the skills that work needs and the level needed for each. People hold job roles; the level a person holds in each skill is compared with the level their roles require. The same computation drives the app, the person’s skills record and these endpoints, so they always agree.
Levels, lowest to highest: awareness, developing, proficient, expert.
The Job Role Object
{
"id": "2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f",
"name": "Site supervisor",
"description": "Leads a shift on site",
"key": null,
"is_active": true,
"required_skills": [
{
"skill_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"skill_name": "Forklift operation",
"required_level": "proficient",
"reviewed_at": "2026-10-01T14:00:00.000Z"
}
],
"holder_count": 12,
"created_at": "2026-09-20T09:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
}| Field | Description |
|---|---|
key | Set for roles managed by imported content (the Embay Academy); null for roles you create |
required_skills | The active skills the role requires and the level for each. reviewed_at is when the level was last set |
holder_count | Live people holding the role (a person holding it through two sources counts once) |
Endpoints
List Job Roles
GET /api/v1/job-rolesAuthentication required: read scope
| Parameter | Type | Required | Description |
|---|---|---|---|
search | string | No | Search on the role name |
include_inactive | boolean | No | true lists inactive roles too. Default: false |
page | integer | No | Default: 1 |
per_page | integer | No | Default: 50, max: 200 |
Ordered by name.
Create a Job Role
POST /api/v1/job-rolesAuthentication required: write scope
{
"name": "Site supervisor",
"description": "Leads a shift on site",
"required_skills": [
{ "skill_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "required_level": "proficient" }
]
}| Field | Rules |
|---|---|
name | Required, at most 150 characters, unique among live roles (ignoring case) |
description | Optional, at most 1,000 characters |
required_skills | Optional, at most 100; each skill once, active skills only |
Returns 201 with the job role. 409 when the name is taken; 400 for an unknown or inactive skill.
Get a Job Role
GET /api/v1/job-roles/{jobRoleId}Authentication required: read scope. 404 when the role does not exist or was deleted.
Update a Job Role
PATCH /api/v1/job-roles/{jobRoleId}Authentication required: write scope
Send any of name, description, is_active and required_skills. When required_skills is sent, it replaces the role’s required levels: skills not listed stop being required, and [] removes them all. A requirement on a deactivated skill is not visible through the API and is never removed by this call. There is no delete endpoint: set is_active to false to stop a role from counting.
Replace a Person’s Imported Job Roles
PUT /api/v1/users/{userId}/job-rolesAuthentication required: write scope
Use it to sync job titles from your HR system. The body lists the job roles the person should hold from this integration:
{ "job_role_ids": ["2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f"] }- The call replaces only the person’s imported roles (the same source as the CSV import). A role an administrator assigned by hand, or one that follows a profile field, is never touched, so a sync cannot undo a person’s decision.
[]removes every imported role. At most 50 ids; every role must exist and be active.- Each role the person did not already hold fires the
job_role.assignedwebhook.
The response lists what was added and removed, and every role the person holds, with its sources:
{
"data": {
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"added": ["2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f"],
"removed": [],
"job_roles": [
{ "job_role_id": "2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f", "name": "Site supervisor", "is_active": true, "sources": ["import", "manual"] }
]
},
"meta": null,
"error": null
}Get a Person’s Skills
GET /api/v1/users/{userId}/skillsAuthentication required: read scope. locale (en or fr-CA) chooses the language of skill and role names.
{
"data": {
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"requirements": "job_roles",
"job_roles": [{ "id": "2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f", "name": "Site supervisor" }],
"summary": { "required_skills": 3, "satisfied": 2, "to_develop": 1, "counts": { "below_1": 1, "meets": 2 } },
"skills": [
{
"skill_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"name": "Forklift operation",
"domain": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Safety" },
"status": "below_1",
"required_level": "proficient",
"required_by": { "kind": "job_role", "id": "2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f", "name": "Site supervisor" },
"attained_level": "developing",
"source": "completion",
"earned_at": "2026-09-01T00:00:00.000Z",
"valid_until": null,
"expiring": false,
"pending_claim_level": null
}
]
},
"meta": null,
"error": null
}requirements | Meaning |
|---|---|
job_roles | The person holds roles that require skills |
none | No role, or roles that require nothing yet: no level is expected |
status | Meaning |
|---|---|
below_2 | Two or more levels below the requirement |
below_1 | One level below |
not_assessed | Required, but no level held |
meets / exceeds | At or above the requirement |
not_required | Held, but no role requires it |
The list holds the skills the person holds or is required to hold, required skills first, most urgent first. Evidence notes and self-assessment notes are not included: they stay in the app.
Get the Team Skills Matrix
GET /api/v1/skills/matrixAuthentication required: read scope. Needs Skills planning (Growth and up) and the organization’s skills-planning switch on (Settings → Skills planning). The API key sees the whole organization. Every pull is recorded in the audit log (the filters and the row count, never the content).
| Parameter | Type | Description |
|---|---|---|
group_id | UUID list | People in these groups |
job_role_id | UUID list | People holding these roles; with no skill_id, the columns are the skills these roles require |
domain_id | UUID list | Skills in these domains |
skill_id | UUID list | The columns (at most 40) |
status | list | Keep people with at least one cell in these statuses |
search | string | First or last name |
locale | string | en or fr-CA |
page, per_page | integer | Default 1 and 50, max 200 |
Lists accept comma-separated values (?status=below_1,below_2) or a repeated parameter.
The app’s matrix can also filter on a custom user field. The API does not offer that filter: a field an administrator keeps private would leak through who matches it. Filter by group or job role instead.
Like every list, data is the page of rows (one per person). The columns and the matrix-level flags are in meta, beside the pagination:
{
"data": [
{
"id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"user": { "id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789", "first_name": "Ada", "last_name": "Roy" },
"job_roles": ["Site supervisor"],
"cells": [
{ "skill_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "status": "below_1", "required_level": "proficient", "attained_level": "developing", "valid_until": null, "expiring": false, "pending_claim_level": null }
]
}
],
"meta": {
"page": 1,
"per_page": 50,
"total": 1,
"columns": [
{ "skill_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "name": "Forklift operation", "domain": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Safety" }, "is_critical": true }
],
"columns_truncated": false,
"available_columns": 1,
"has_requirements": true,
"population_truncated": false
},
"error": null
}Each row’s cells follow the order of meta.columns. columns_truncated is true when more than 40 skills matched (narrow with domain_id or skill_id); population_truncated is true when a status filter scanned the first 10,000 people only.
Plan errors
A plan without the feature answers 403 with an entitlement_blocked block, so you can tell a plan limit from a key problem:
{
"data": null,
"meta": null,
"error": {
"code": 403,
"message": "Skills planning is not included in your plan",
"entitlement_blocked": { "code": "ENTITLEMENT_BLOCKED", "flag_or_limit": "skills_planning", "current_tier": "starter", "required_tier": "growth" }
}
}When the plan includes skills planning but your organization turned it off, the matrix answers 403 with "reason": "SKILLS_PLANNING_OFF".
Webhooks
job_role.assigned: a person received a job role on their user page or through this API.skill.attained: a person’s held level in a skill rose.
See Webhooks for the payloads.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
403 with entitlement_blocked | Your plan does not include the feature | Upgrade, or ask your account manager |
403 with reason: SKILLS_PLANNING_OFF | Skills planning is turned off | An Owner or Admin turns it on in Settings → Skills planning |
400 Unknown or inactive skill | A skill_id does not exist or was deactivated | List the skills in the app’s Skills Library |
409 on create or rename | Another live role has that name | Choose another name, or update the existing role |