Skill Management APIs
Skill Management API
Create, read, update and delete skill groups programmatically.
Contents
- Getting started
- How responses work
- Endpoints
- Skill settings reference
- Agent membership
- Behaviour you need to understand
- Building an integration
- Limits and known gaps
Getting started
Base URL
https://<your-tenant>/rest/api/v1/skills
Replace <your-tenant> with your own hostname. Note the /rest prefix — it is easy to leave out.
Authentication
Every request needs a Service User token:
Authorization: Bearer <your-token>
Create a Service User in the web interface under API Enablement → Service Users, then copy its token.
Any valid Service User token can create, edit and delete skills. There is no separate
permission for skill administration. If you have tokens issued for other integrations, they
can all reach these endpoints. Every change is recorded in the settings audit log against the
Service User that made it.
Content type
All request bodies are JSON. Send Content-Type: application/json.
Your first call
curl -s "https://<your-tenant>/rest/api/v1/skills" \
-H "Authorization: Bearer $TOKEN"How responses work
There are two kinds of failure, and telling them apart matters.
Request-level failures → HTTP error status
The whole request was rejected. Nothing was written.
{
"exception": "ApiConflictException",
"message": "\"Support\" is this tenant's default skill and cannot be deleted. Make another skill the default first."
}Item-level failures → HTTP 200, with status: "REJECTED"
status: "REJECTED"The request was understood and processed, but one or more skills in it could not be written.
This still returns 200. A bulk request where three of four skills succeed is a successful
request with one rejected item — not an error.
{
"results": [
{ "index": 0, "id": "9f2c…", "name": "Sales", "status": "CREATED", "warnings": [], "errors": [] },
{ "index": 1, "name": "Billing", "status": "REJECTED", "warnings": [],
"errors": [{ "field": "extension", "reason": "Extension 5100 is already used by skill \"Support\"." }] }
],
"summary": { "created": 1, "updated": 0, "deleted": 0, "rejected": 1 }
}Always check summary.rejected and each result's status. Do not rely on the HTTP status alone.
Warnings
A warning means the write happened, and something in it was adjusted. Every write response
carries a warnings[] array per item.
{
"field": "voiceSettings.queueMusic",
"submitted": "hold-music-v2",
"action": "DEFAULTED",
"resolvedTo": "snap-clap-whistle.ul",
"reason": "No queue music with that name exists on this tenant."
}action | Meaning |
|---|---|
DEFAULTED | Your value did not resolve, so the product default was applied. resolvedTo says what. |
CLEARED | Your value did not resolve and there is no default, so the setting was left unset. Read the effect field — for a routing destination this disables that routing. |
IGNORED | The field name was not recognised, or an agent entry could not be matched. Nothing was applied for it. |
Warnings are how you catch typos. A misspelled field name is not an error — it is silently not
applied — so IGNORED warnings are the only signal that you sent something the API did not
understand. Log them.
Result statuses
CREATED · UPDATED · DELETED · REJECTED
You may notice a
rejected: true/falsefield on each result. It duplicatesstatusand can
be ignored.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /rest/api/v1/skills | List all skills |
GET | /rest/api/v1/skills/{id} | One skill in full |
POST | /rest/api/v1/skills/create | Create a skill |
PATCH | /rest/api/v1/skills/{id} | Update a skill |
PATCH | /rest/api/v1/skills | Create and update many at once |
DELETE | /rest/api/v1/skills/{id} | Delete a skill |
Skills are addressed by id (a UUID), not by name. Use the list endpoint to map your own
names to ids.
List skills
GET /rest/api/v1/skills
Response 200
{
"skills": [
{ "id": "70f9cf55-0963-44b7-88de-cb154f1dc598", "name": "CC Sales", "extension": "2101", "isDefault": false },
{ "id": "28c4f023-a442-4f35-9a63-d65d82d6901d", "name": "CC Support", "extension": "2102", "isDefault": true }
]
}Sorted by name. extension is omitted for a skill that has none.
| Status | Meaning |
|---|---|
200 | Success |
401 | Missing or invalid token |
Get one skill
GET /rest/api/v1/skills/{id}
Response 200 (settings abbreviated)
{
"id": "70f9cf55-0963-44b7-88de-cb154f1dc598",
"name": "CC Sales",
"extension": "2101",
"isDefault": false,
"settings": {
"skillName": "CC Sales",
"extension": "2101",
"aliasNumbers": [],
"priority": 10,
"voiceSettings": { "routingAlgorithm": "LINEAR", "agentRingTimeSeconds": 30 },
"callbackSettings": { "callbackStrategy": "WAIT_IN_QUEUE" },
"messagingSettings": { "inviteHeaderText": "Tell us about yourself" }
},
"agents": [
{
"agent": "Agent Kelly(556)",
"name": "Agent Kelly",
"extension": "556",
"skillLevel": 10,
"maxSimultaneousSessions": 2,
"voiceEnabled": true,
"chatEnabled": true,
"emailEnabled": true
}
]
}The settings object is the same shape the write endpoints accept, so you can fetch a
skill, change a value, and send it back.
agents lists only agents who are both assigned to the skill (skill level above 0) and
currently licensed. An agent who still holds a stored skill level but has lost their
licence is not listed, because they cannot take interactions.
| Status | Meaning |
|---|---|
200 | Success |
401 | Missing or invalid token |
404 | No skill with that id |
Create a skill
POST /rest/api/v1/skills/create
Required: skillName only. Everything else takes a product default.
Request
{
"skillName": "Customer Care",
"extension": "2091",
"priority": 10,
"voiceSettings": {
"routingAlgorithm": "LINEAR",
"agentRingTimeSeconds": 30
},
"callbackSettings": {
"callbackStrategy": "WAIT_IN_QUEUE"
},
"agents": [
{ "agent": "Agent Kelly(556)", "skillLevel": 10 }
]
}Response 200
{
"results": [
{ "index": 0, "id": "7f4fa573-1acc-413c-9676-d380ee3f495f", "name": "Customer Care",
"status": "CREATED", "warnings": [], "errors": [] }
],
"summary": { "created": 1, "updated": 0, "deleted": 0, "rejected": 1 }
}The new skill's id is in results[0].id. Store it.
Item rejected when: skillName is missing, the name is already in use, the extension is
already in use, or the body cannot be read (for example a value that is not a valid choice for
its field). These come back as 200 with status: "REJECTED".
| Status | Meaning |
|---|---|
200 | Processed. Check status — may still be REJECTED |
400 | No body supplied, or the agents list could not be read |
401 | Missing or invalid token |
429 | Another write is in progress. Retry |
Update a skill
PATCH /rest/api/v1/skills/{id}
Required: nothing. Send only what you are changing.
Request — turning callbacks off and nothing else:
{ "callbackSettings": { "callbackStrategy": "NO_QCB" } }Every other callback setting, and all voice and messaging settings, are left exactly as they
were. See Partial updates.
Response 200
{
"results": [
{ "index": 0, "id": "70f9cf55-0963-44b7-88de-cb154f1dc598", "name": "CC Sales",
"status": "UPDATED", "warnings": [], "errors": [] }
],
"summary": { "created": 0, "updated": 1, "deleted": 0, "rejected": 0 }
}Item rejected when: the id does not exist, you attempt a rename, the extension clashes with
another skill, or the body cannot be read.
| Status | Meaning |
|---|---|
200 | Processed. Check status |
400 | No body supplied, or the agents list could not be read |
401 | Missing or invalid token |
429 | Another write is in progress. Retry |
Create and update many skills
PATCH /rest/api/v1/skills
Use this for anything involving more than a couple of skills. It is not just fewer round
trips: each write triggers a full configuration save and, on Netsapiens tenants, a complete
dial-plan reconciliation. A bulk request does that once regardless of how many skills it
carries. Five hundred single-skill calls will do it five hundred times.
Request — one create, one update:
{
"skills": [
{ "settings": { "skillName": "Overflow Team", "extension": "2400" } },
{ "id": "70f9cf55-0963-44b7-88de-cb154f1dc598", "settings": { "priority": 5 } }
]
}Each item: id present means update, id absent means create. Agents go alongside
settings:
{ "id": "70f9cf55-…", "settings": { "priority": 5 }, "agents": [ { "agent": "Agent Kelly(556)", "skillLevel": 8 } ] }Response 200 — one result per item, in the order you sent them (index matches):
{
"results": [
{ "index": 0, "id": "aa11…", "name": "Overflow Team", "status": "CREATED", "warnings": [], "errors": [] },
{ "index": 1, "id": "70f9cf55-…", "name": "CC Sales", "status": "UPDATED", "warnings": [], "errors": [] }
],
"summary": { "created": 1, "updated": 1, "deleted": 0, "rejected": 0 }
}A failing item does not stop the others. One bad extension in item 300 of 500 does not
discard the other 499.
| Status | Meaning |
|---|---|
200 | Processed. Check each status and summary.rejected |
400 | No skills supplied, or more than 500 items |
401 | Missing or invalid token |
429 | Another write is in progress. Retry |
Delete a skill
DELETE /rest/api/v1/skills/{id}
Required: the skill's extension in the body, as confirmation.
{ "extension": "2101" }For a skill with no extension, confirm its name instead:
{ "name": "Overflow Team" }Response 200
{
"results": [
{ "index": 0, "id": "70f9cf55-…", "name": "CC Sales", "status": "DELETED", "warnings": [], "errors": [] }
],
"summary": { "created": 0, "updated": 0, "deleted": 1, "rejected": 0 }
}Deletion is refused with 409 when:
- It is your tenant's default skill. Make another skill the default first.
- The skill currently has calls in queue.
- Another skill routes to it — via queue timeout, queue overflow, max calls in queue, or
end-of-day overflow. The message names every referring skill and which route. Clear those
destinations first.
The last guard exists because those references are by name. Deleting a skill another skill
overflows into does not raise an error at call time — the overflow simply never fires, and
callers wait in the referring skill's queue as though nothing were configured.
| Status | Meaning |
|---|---|
200 | Deleted |
400 | No body, or the confirmation does not match the skill |
401 | Missing or invalid token |
404 | No skill with that id |
409 | Blocked by a guard — see the message |
429 | Another write is in progress. Retry |
Deleting a skill affects historical reporting. Skills are recorded by name on completed
interactions, so reports filtered on a deleted skill's name keep their history but gain no
new data. This cannot be undone by recreating a skill with the same name — though that does
restore future reporting.
Skill settings reference
All of these live inside the settings object (the request body for single-skill writes, or
settings for bulk items). All are optional.
Top level
| Field | Type | Notes |
|---|---|---|
skillName | string | Required on create. Must be unique. Cannot be changed |
extension | string | Must be unique. Can be changed |
aliasNumbers | string[] | Additional numbers or extensions routing to this skill |
priority | integer | Higher wins when an agent serves several skills |
recordingMode | enum | UNDEFINED · AUTOMATIC · AUTOMATIC_PAUSING_PROHIBITED · MANUAL · NEVER. Omit to defer to each agent's own mode |
voiceSettings
voiceSettings| Field | Type | Notes |
|---|---|---|
routingAlgorithm | enum | MOST_IDLE · CIRCULAR · LINEAR · HIGHEST_SKILL · NO_ROUTING |
availabilityTiers | object | { "tiers": [] } — empty offers to all eligible agents at once |
languagePackId | string | Language for system prompts |
queueAnnouncements | object | See below |
queueMusic | string | Hold music name |
queueTimeout | object | timeoutMillis plus a destination |
queueOverflow | object | overflowCondition (NEVER · NO_AGENTS_ENABLED · NO_AGENTS_READY), checkFrequencySeconds, plus a destination |
maxCallsInQueue | object | maxAllowed plus a destination. 0 means uncapped |
clearCallsAtEndOfDay | object | endOfDayInSeconds; 0 disables |
digitActions | object | Keypress options while queued |
webrtcAutoAnswer | boolean | Connect browser agents without them answering |
agentRingTimeSeconds | integer | How long one agent rings before moving on |
transcriptLanguage | enum | ENGLISH_US · ENGLISH_UK · ENGLISH_AU · FRENCH · FRENCH_CANADIAN · SPANISH · ITALIAN · DUTCH · DENMARK_DANISH · CHINESE_MANDARIN · CHINESE_CANTONESE |
skillVoicemail | object | enabled, greeting (audio id), emailAddresses |
Destinations. queueTimeout, queueOverflow, maxCallsInQueue and
clearCallsAtEndOfDay all take destination plus destinationType:
destinationType | destination holds |
|---|---|
SKILL | Another skill's name (not its id) |
NUMBER | A phone number or extension |
IVR | An IVR call flow name |
SKILL_VOICEMAIL | Nothing — this skill's own voicemail. Requires skillVoicemail.enabled |
Queue announcements.
"queueAnnouncements": {
"announcements": [
{
"startTimeSeconds": 5,
"repeatIntervalSeconds": 90,
"repeatLimit": 2147483647,
"announcementName": "Position in Queue",
"isCallbackRelated": false
}
],
"endOfQueueAnnouncement": "This call is recorded"
}repeatLimit of 2147483647 means repeat indefinitely. isCallbackRelated: true plays the
announcement only when a callback is being offered.
Digit actions are keyed by the digit pressed:
"digitActions": {
"digitActions": {
"1": { "destination": "<Queued Callback Module>", "destinationType": "NUMBER" }
}
}<Queued Callback Module> is a reserved token, not a real number — it hands the caller to the
queued-callback flow.
callbackSettings
callbackSettings| Field | Type | Notes |
|---|---|---|
callbackStrategy | enum | NO_QCB (off) · WAIT_IN_QUEUE · RESERVE_AGENT |
callbackSnoozeSeconds | integer | Wait before retrying a failed attempt |
outboundDialCode | string | Prefix when dialling out, e.g. "9" |
callbackOfferWindow | object | startSecondOfDay / endSecondOfDay. 0–86400 is all day |
callbackAttemptWindow | object | Same shape |
maxPendingCallbacks | integer | |
clearPendingCallbacks | enum | NEVER · NIGHTLY · MIDNIGHT · AM_1–AM_11 · PM_12 · PM_1–PM_11 |
maxCallbackAttempts | integer | 0 is unlimited |
attemptToGuessNumber | boolean | Infer the callback number from caller ID |
includeEstimatedWaitTime | boolean | Speak the wait estimate when offering |
messagingSettings
messagingSettings| Field | Type | Notes |
|---|---|---|
inviteHeaderText | string | |
whenAllAgentsBusy | object | option: HIDE · OFFER_EMAIL · OFFER_QUEUE, plus titleText, emailAddress |
whenNoAgentsLoggedIn | object | option: HIDE · OFFER_EMAIL (no queue option — there is nobody to queue for) |
cannedMessages | array | title, body, isInitialMessage. $customerName and $agentName are substituted at send time. Keep at most one isInitialMessage: true |
customChatText | object | { "customizations": {} } — chat widget text overrides |
agentResponseTimeoutSeconds | integer | Idle agent before the chat is reclaimed |
externalPartyTimeoutSeconds | integer | Idle customer before the chat closes |
autoAnswer | boolean |
Agent membership
Send an agents array on create or update to manage who is in a skill.
"agents": [
{ "agent": "Agent Kelly(556)", "skillLevel": 10, "maxSimultaneousSessions": 2 },
{ "name": "Randy Ross", "extension": "1990", "skillLevel": 5 },
{ "agent": "Agent Jon(557)", "skillLevel": 0 }
]| Field | Notes |
|---|---|
agent | The name(extension) key exactly as GET /skills/{id} returns it. The unambiguous option |
name + extension | Alternative to agent. Use both together |
skillLevel | 1–10 to assign, 0 to remove. Required when adding an agent who is not already in the skill |
maxSimultaneousSessions | Concurrent interactions |
voiceEnabled / chatEnabled / emailEnabled | Per-channel participation |
Three rules
1. Omitting agents changes nothing. Membership is left exactly as it was.
2. Supplying agents never removes anyone. Listed agents are added or updated; agents
already in the skill and not listed are untouched. To remove an agent, send them with
skillLevel: 0. This means a partial or filtered list can never silently strip a skill.
3. Ambiguous agents are skipped, not guessed. A name or extension on its own is often
not unique — several agents can share a name, and two agents can share an extension. If your
value matches more than one agent the entry is skipped with an IGNORED warning listing the
candidates, and you should re-send it using agent, or name and extension together.
Unrecognised or unlicensed agents are also skipped with a warning. The rest of the skill still
writes.
Behaviour you need to understand
Partial updates (merge semantics)
On PATCH, only the fields present in your body change. Everything omitted keeps its
current value, at every level of nesting. This:
{ "voiceSettings": { "agentRingTimeSeconds": 20 } }changes the ring time and leaves the routing algorithm, queue music, overflow, timeout and
every other voice setting alone.
To clear an optional setting, send it explicitly as null:
{ "voiceSettings": { "queueOverflow": null } }Omitting a field means "leave it alone". Only an explicit null removes something.
Lists and maps replace, they do not merge. Sending aliasNumbers: ["5101"] sets the list
to exactly that. Same for cannedMessages and customChatText.customizations.
On POST .../create, the same rule applies against product defaults: anything you omit is
created with its default value.
Renaming is not supported
A PATCH whose skillName differs from the skill's current name is rejected (200 with
status: "REJECTED"). Other fields in the same request still apply.
This is deliberate. Skills are referenced by name in several places, including on completed
interactions already stored. A rename would break routing that points at the old name, and
would split reporting history at the date it happened in a way that cannot be repaired. If you
need a different name, create a new skill and move membership across.
Extensions can be changed freely, subject to uniqueness.
Unrecognised references are defaulted, not rejected
If you send an audio name, language pack, caller ID or skill destination that does not exist,
the API applies a sensible fallback and reports it in warnings[] rather than failing your
request. A single stale audio id will never kill a bulk load of hundreds of skills.
The important case is a SKILL destination naming a skill that does not exist. It is
cleared, which disables that routing entirely — no overflow, no timeout. The warning says
so in its effect field. These are the warnings to act on.
Note that a value which is not a valid choice for its field (a misspelled enum, say) is
different: that is malformed input with no sensible fallback, so the item is rejected.
Order does not matter in a bulk request
Skill destinations are resolved after every skill in the request has been applied, so this
works even though Ref A names a skill that does not exist yet when it is processed:
{
"skills": [
{ "settings": { "skillName": "Ref A", "extension": "5001",
"voiceSettings": { "queueOverflow": { "overflowCondition": "NO_AGENTS_ENABLED",
"destinationType": "SKILL", "destination": "Ref B", "checkFrequencySeconds": 5 } } } },
{ "settings": { "skillName": "Ref B", "extension": "5002" } }
]
}You do not need to sort your skills so that referenced skills come first.
One write at a time
Skill writes are serialised. A write arriving while another is in progress gets 429
rather than being queued. Retry on 429 — a short backoff is enough. Do not run parallel
write requests; you will simply get 429s and no extra throughput.
Reads are not affected.
Building an integration
Map your names to ids once
Skills are addressed by id, but your own system probably keys on names or extensions. Fetch the
list once and cache the mapping:
curl -s "$BASE/rest/api/v1/skills" -H "Authorization: Bearer $TOKEN" \
| jq 'reduce .skills[] as $s ({}; .[$s.name] = $s.id)'Refresh it after any create.
Read, change, write back
The settings object from GET /skills/{id} is exactly what the write endpoints accept:
curl -s "$BASE/rest/api/v1/skills/$ID" -H "Authorization: Bearer $TOKEN" \
| jq '{settings: .settings, agents: .agents}' > skill.json
# edit skill.json, then send just the settings object plus agentsIn practice you rarely need this — sending only the fields you are changing is smaller, safer
and less likely to trip the rename guard.
Bulk load in batches of 500
for each batch of up to 500 skills:
PATCH /rest/api/v1/skills with {"skills": [...]}
on 429 -> back off briefly and retry the same batch
for each result:
if status == REJECTED -> log result.errors, flag for a human
if warnings non-empty -> log them
Do not send batches in parallel. One at a time, sequentially.
Handle every response properly
if HTTP >= 400:
the whole request failed; read .message
else:
for each result:
REJECTED -> your data needs fixing, see .errors
otherwise -> written. Check .warnings:
DEFAULTED -> a reference did not resolve; a default was used
CLEARED -> a setting was removed; read .effect
IGNORED -> a field name or agent was not recognised — probably a typo
Treating IGNORED warnings as harmless is the most likely way to have a setting silently not
apply.
Limits and known gaps
| Bulk size | 500 skills per request. More returns 400 |
| Concurrency | One write at a time across the whole tenant. Others get 429 |
| Rename | Not supported |
| Agent removal | Requires an explicit skillLevel: 0 |
Voicemail greeting ids are not validated. An invalid skillVoicemail.greeting is stored
without a warning. Verify by listening to the skill's greeting after a write.
IVR references are not checked on delete. A skill that only an IVR call flow routes to can
be deleted without a 409. Check IVR call flows yourself before deleting a skill.
Web interface edits are not coordinated with the API. If an administrator saves a skill in
the web interface while your integration is writing, one of the two changes can be lost. Avoid
running bulk loads while administrators are working in the Skills panel.
Unlicensed agents keep their stored membership. They are not returned by
GET /skills/{id} and cannot be written, but their skill levels remain and reappear if the
agent is licensed again.
Updated about 2 hours ago
