Appearance
Agents & seats
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Seat roster, presence, softphone checkin/checkout.
Endpoints
The latest recording-and-supervision acceptance of one agent
http
GET /v1/agents/{id}/monitoring-acknowledgementThe current notice version, whether THIS agent has accepted it (current) and the agent's latest acceptance of any version (null if none). A seat reads its own; a seat whose template role is supervisor or admin and a dd_ tenant key read any agent of the tenant; another seat reading a colleague's is 403 forbidden. An id from another tenant is 404. A ddw_ widget token is 403 before the handler.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The agent's monitoring-notice state.
| Field | Type | Required | Description |
|---|---|---|---|
monitoring | object (AgentMonitoringStatus) | yes | An agent's standing against the current notice. |
monitoring.notice_version | integer | null | yes | The current version; null while none is in force. |
monitoring.current | boolean | yes | True only when the agent has accepted the CURRENT version. |
monitoring.acknowledgement | AgentMonitoringAck | null | yes | The agent's latest acceptance of any version; null if none. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}What the dialer has in front of the caller's OWN seat
http
GET /v1/agent/queueA READ composed from four sources that already exist; there is no per-agent queue table and none is invented. For the seat the BEARER identifies — no parameter may name an agent, so another agent's queue cannot be read:
presence: the seat FSM state, the same asGET /v1/agents.live_call: the seat's in-flight call (CDR), ornull.callbacks: pending callbacks the agent SCHEDULED, soonest first (callbacks.agent_idis the creator, not an assignment: the dialer does not assign callbacks per agent). At most 50.inbound_offers: live inbound-queue offers addressed to this seat, derived from the durable queue stream;[]when the tenant'sinbound_queue_stream_enabledflag is off.
NOT present, because the model has no such state: a per-agent "preview pending" lead (in preview mode the account shown is decided when it is dialed). Reserved/ringing calls appear as presence.
Who gets a seat: a ddu_ dashboard login with a linked seat. A dd_ machine key has no person behind it and is 403; a ddu_ without an active seat is 403 seat_not_linked; a ddw_ widget token is 403 before the handler (the route is not on the widget allowlist).
Deliberately absent (same projection contract as /v1/debts): phone numbers, the callback note and the attempt compliance snapshot — ids, timestamps and priority only.
Responses
200
The caller's own queue.
| Field | Type | Required | Description |
|---|---|---|---|
queue | object (AgentQueue) | yes | What the dialer has in front of one seat (GET /v1/agent/queue). Ids, timestamps and priority only — no PII. |
queue.agent_id | string (uuid) | yes | |
queue.presence | offline · available · ringing · on_call · wrap_up · paused | yes | |
queue.live_call | object,null | yes | |
queue.live_call.attempt_id | string (uuid) | yes | |
queue.live_call.campaign_id | string,null (uuid) | yes | |
queue.live_call.debt_id | string,null (uuid) | yes | |
queue.live_call.started_at | string (date-time) | yes | |
queue.live_call.answered_at | string,null (date-time) | yes | |
queue.callbacks | array of object | yes | |
queue.callbacks[].id | string (uuid) | yes | |
queue.callbacks[].debt_id | string,null (uuid) | yes | |
queue.callbacks[].campaign_id | string,null (uuid) | yes | |
queue.callbacks[].scheduled_at | string (date-time) | yes | |
queue.callbacks[].priority | integer | yes | |
queue.inbound_offers | array of object | yes | |
queue.inbound_offers[].queue_id | string (uuid) | yes | |
queue.inbound_offers[].admission_id | string (uuid) | yes | |
queue.inbound_offers[].call_id | string (uuid) | yes | |
queue.inbound_offers[].enqueued_at | string (date-time) | yes |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}Roster + live seat presence
http
GET /v1/agentsDurable roster merged with live presence (offline|available|ringing|on_call).
Responses
200
All agents with presence.
| Field | Type | Required | Description |
|---|---|---|---|
agents | array of Agent | yes | |
agents[].id | string (uuid) | no | |
agents[].tenant_id | string (uuid) | no | |
agents[].name | string | no | |
agents[].email | string | no | |
agents[].role | agent · supervisor · admin | no | |
agents[].status | active · inactive | no | Roster flag — NOT presence. |
agents[].sip_extension | string | null | no | |
agents[].device_mode | browser · external | no | |
agents[].login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agents[].presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agents[].pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agents[].state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agents[].pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agents[].created_at | string (date-time) | no | |
agents[].updated_at | string (date-time) | no | |
truncated | boolean | yes | true when there are more rows than the 2 000 agents the listing returns: the response is a prefix in the documented order, not the whole set. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Create a seat
http
POST /v1/agentsRequest body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
email | string (email) | yes | |
role | agent · supervisor · admin | no | |
sip_extension | string | no | The SIP registration identity calls bridge to. Only inert characters (digits, letters, dot, underscore, hyphen): the value is both the SIP identity and half of the media dialstring, so anything else is refused with 422 rather than diverging between the two. |
json
{
"name": "Postman Seat",
"email": "[email protected]",
"role": "agent",
"sip_extension": "1009"
}Responses
201
Created seat.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422
The seat is refused and no row is written. Branch on code:
seat_limit_reached— the tenant already holds themax_seatsactive seats of the tier in force (tier). A seat over the cap is never billed as an extra: free a seat (aninactiveone does not count) or move to a larger tier. The cap follows a tier change at once. A tenant that moved to a smaller tier keeps every seat it had, soactive_seatscan be abovemax_seats; only new seats are refused until it is back under.enterprisehas no cap.unpriceable_tier— the tenant's tier has no priced catalog row, so its cap cannot be read. The seat is refused rather than created without a cap.
seat_limit_reached
json
{
"error": {
"code": "seat_limit_reached",
"message": "tier starter allows 5 active seats and the tenant already has them",
"max_seats": 5,
"tier": "starter",
"active_seats": 5
}
}unpriceable_tier
json
{
"error": {
"code": "unpriceable_tier",
"message": "the tenant's billing tier has no priced catalog row (tier_not_in_catalog)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Derived time by state and seat for an explicit window
http
GET /v1/agents/occupancyTenant-management surface (dd_ or ddu_; an agent ddw_ is 403). Returns Dialer.Agents.Occupancy.by_agent/4 without a roster join or a client-facing recomputation: a seat with no interval evidence in the window is absent instead of appearing as a fabricated zero.
The half-open window is entered_at ∈ [from, to). A counted interval contributes its whole monotonic duration_ms; open and synthetic intervals are excluded and reported under excluded. Each seat carries an occupancy ratio under a named, versioned definition (v1): (on_call + wrap_up) / (available + ringing + on_call + wrap_up), i.e. counted time minus paused. A later formula is published as a new definition next to v1; v1 never changes meaning.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | yes | Inclusive start of the interval-entry window (RFC 3339). |
to | query | string (date-time) | yes | Exclusive end of the interval-entry window (RFC 3339); must be after from. |
agent_id | query | string (uuid) | no | Optional seat filter, always inside the tenant resolved from the bearer. |
Responses
200
The occupancy derivation for seats with evidence in the window.
| Field | Type | Required | Description |
|---|---|---|---|
window | object | yes | |
window.from | string (date-time) | yes | |
window.to | string (date-time) | yes | |
agents | object | yes | Map keyed by agent UUID; seats without interval evidence are absent. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Handle time per agent, crediting each agent only its legs of a transferred call
http
GET /v1/agents/handle-timeTenant-management surface (dd_ or ddu_; an agent ddw_ is 403). Derived from the append-only call-control trail and the CDR clock (Dialer.Calls.TransferChain.handle_time/4, core#2403): a call transferred A -> B -> C is credited to A until the hand-off, to B from the hand-off to the next, and to C from there to the end — not whole to the agent the CDR row names. control_ms is the time holding the call, consult_ms the time on a consultation leg, occupied_ms their sum and aht_ms is control_ms / calls (null when the agent held none, never a fabricated zero). Calls are those that STARTED in the half-open window [from, to). A leg whose duration is not measurable yet (the call is live) is left out and counted in excluded.open_legs. Control that left the agent population (an external transfer) credits no agent.
The window may span at most 31 days (400 otherwise) and at most 10,000 calls (422: it is refused, never truncated). definition: "v1" never changes meaning; a later formula is published as v2.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | yes | Inclusive start of the call-start window (RFC 3339). |
to | query | string (date-time) | yes | Exclusive end of the call-start window (RFC 3339); must be after from. |
agent_id | query | string (uuid) | no | Optional seat filter, always inside the tenant resolved from the bearer. |
Responses
200
Handle time for the agents that held or consulted on a call in the window.
| Field | Type | Required | Description |
|---|---|---|---|
definition | v1 | yes | |
from | string (date-time) | yes | |
to | string (date-time) | yes | |
agents | array of object | yes | |
agents[].agent_id | string (uuid) | yes | |
agents[].calls | integer | yes | Calls in which the agent held control for a measurable time. |
agents[].control_ms | integer | yes | |
agents[].consult_ms | integer | yes | |
agents[].occupied_ms | integer | yes | |
agents[].aht_ms | integer | null | yes | |
excluded | object | yes | |
excluded.open_legs | integer | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}503
The evidence could not be read; retry. Fail-closed — never an empty answer.
Fetch one agent + presence
http
GET /v1/agents/{id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The agent.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Edit a seat or release it (status inactive)
http
PATCH /v1/agents/{id}Corrects a seat created by POST /v1/agents and is the verb that releases it (core#2527). Admin only: a dd_ key or a dashboard login with account_admin; any other login is 403 role_forbidden. The body is closed: an unknown key is 400 naming it. Send only what changes.
status: "inactive"releases the seat: it stops counting against the plan cap and the invoice (inactiveholds no seat), the seat's liveddw_tokens are revoked, it is checked out and its SIP credential is evicted (sip_teardown:ok | skipped | failed). The row is never deleted;deactivated_atis stamped by the database.status: "active"on an inactive seat takes the same cap as creation:422 seat_limit_reached(atomic under the tenant lock) or422 unpriceable_tier.409 last_admin_seat— the seat is the only activeadminseat and the edit would demote or deactivate it.409 seat_linked_to_user— the seat is linked to a console user and the edit changes its email. The link is kept, never re-pointed, so the address cannot change here.409 conflict— the email or the extension is already taken.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
email | string | no | |
role | agent · supervisor · admin | no | |
sip_extension | string | no | |
status | active · inactive | no |
json
{
"status": "inactive"
}Responses
200
The seat after the edit.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no | |
sip_teardown | ok · skipped · failed | no | Present when the seat was released or its extension changed; names the result of evicting the extension the seat HAD. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422
Reactivation refused: seat_limit_reached or unpriceable_tier (see POST /v1/agents). Nothing is written.
429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}The routing skills of an agent
http
GET /v1/agents/{id}/skillsThe agent's skills, sorted; [] when it has none. An agent that is not in this tenant's roster is 404. Admin only, like the write.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The agent's skills.
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string (uuid) | yes | |
skills | array of string | yes |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403
role_forbidden: a dashboard login without the account_admin role; forbidden: a widget token or a suspended tenant.
404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Replace the routing skills of an agent
http
PUT /v1/agents/{id}/skillsDeclares which skills the agent has (ADR #216 D3). The body REPLACES the whole set: a skill not listed is removed, one missing is added, and a repeated one counts once. A skill is a tenant label ^[a-z0-9][a-z0-9_-]{0,39}$ — no global catalog, no levels. 422 unprocessable when any skill is malformed, skills is not an array of strings, or there are more than 50; nothing is written then. An agent that is not in this tenant's roster is 404. Writing works with inbound_queue_skill_routing_enabled OFF: what is stored stays inert until the tenant turns it on. A skill is a routing preference, not an authorization: no compliance gate reads it (ADR #216 D7). Admin only: a dd_ key or a dashboard login with account_admin.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
skills | array of string | yes |
json
{
"skills": [
"idioma_es",
"cobranza_tardia"
]
}Responses
200
The agent's skills after the write, sorted.
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string (uuid) | yes | |
skills | array of string | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403
role_forbidden: a dashboard login without the account_admin role; forbidden: a widget token or a suspended tenant.
404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Enable or rotate an agent's widget-login password
http
POST /v1/agents/{id}/credentialSets (or rotates) the agent's widget-login credential so they can sign into the widget with email + password (POST /v1/auth/agent-login). Tenant scope (a dd_ key or ddu_ session; a ddw_ is 403). Role rules (core#2404): the password opens a ddw_ bound to the seat, so a dd_ key and an account_admin login pass, a ddu_ passes only on the seat users_agents.user_id links to it, and every other login is 403 role_forbidden (per-tenant knob agent_credential_admin_only, default on). The plaintext password is body-only — never persisted (PBKDF2-SHA512 at rest) or echoed back. A ROTATION revokes the agent's live ddw_ (the re-key eviction) AND tears down the SIP endpoint minted under that session (NR-02), reported in sip_teardown.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
password | string (password) | yes |
Responses
200
The agent (now login_enabled true), plus the SIP teardown outcome.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no | |
sip_teardown | ok · skipped · failed | yes | Outcome of the SIP teardown that accompanies a revocation (NR-02): the deletion of the agent's ephemeral agentcred credential and its live usrloc binding in Kamailio. * ok — credential and binding removed; the softphone can no longer register. * skipped — nothing to remove: the agent has no SIP extension, SBC provisioning is not configured in this environment, or the agent still holds a VIGENT session (see below) so this revocation left no access open. * failed — the DB revocation stands, but the SIP credential is STILL LIVE and will remain so until its 24h autoexpire. Treat as an open access path: retry, or evict the agent by other means. It is a field and not an error status on purpose: the revocation itself is durable and did happen, so a 5xx would be a lie in the other direction. What must never happen is a silent 200 implying an eviction that did not occur. The teardown follows the TRANSITION, not the agent. Revoking a token id that was already revoked — an id a client can legitimately hold, since GET /v1/widget_tokens lists revoked rows, and since minting supersedes the previous token — must NOT evict, because the agent's SUCCESSOR session would be the one cut. The teardown therefore runs only when the agent is left with no vigent token; otherwise it reports skipped. This is what keeps failed's retry advice usable: an already-revoked token with no live successor still evicts on a retried DELETE, so retrying is a real remedy and not a no-op. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — AgentCredentialForbidden
Setting or clearing a seat's widget-login password was refused (core#2404, ADR #126 addendum). coderole_forbidden: the caller is a dashboard login that is neither an account_admin nor the login linked to that seat (the route takes a dd_ key, an account_admin login or the seat's own login); the per-tenant knob agent_credential_admin_only (default on) turns this off. Any other 403 is the plain scope refusal (forbidden: a ddw_ or a public-site key).
json
{
"error": {
"code": "role_forbidden",
"message": "setting or clearing a seat's widget password takes a dd_ key, a dashboard login with the account_admin role, or the seat's own login"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Disable an agent's widget login
http
DELETE /v1/agents/{id}/credentialClears the agent's widget-login password (back to login_enabled: false) and revokes any live ddw_ the agent holds, so the disable takes effect at once — INCLUDING the SIP endpoint (NR-02), reported in sip_teardown. Without that half, a disabled agent kept dialling over SIP until the credential's 24h autoexpire. The seat itself is untouched. Idempotent. Tenant scope (a ddw_ is 403). Same role rules as the POST (core#2404): a member login is 403 role_forbidden on any seat but its own.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The agent (now login_enabled false), plus the SIP teardown outcome.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no | |
sip_teardown | ok · skipped · failed | yes | Outcome of the SIP teardown that accompanies a revocation (NR-02): the deletion of the agent's ephemeral agentcred credential and its live usrloc binding in Kamailio. * ok — credential and binding removed; the softphone can no longer register. * skipped — nothing to remove: the agent has no SIP extension, SBC provisioning is not configured in this environment, or the agent still holds a VIGENT session (see below) so this revocation left no access open. * failed — the DB revocation stands, but the SIP credential is STILL LIVE and will remain so until its 24h autoexpire. Treat as an open access path: retry, or evict the agent by other means. It is a field and not an error status on purpose: the revocation itself is durable and did happen, so a 5xx would be a lie in the other direction. What must never happen is a silent 200 implying an eviction that did not occur. The teardown follows the TRANSITION, not the agent. Revoking a token id that was already revoked — an id a client can legitimately hold, since GET /v1/widget_tokens lists revoked rows, and since minting supersedes the previous token — must NOT evict, because the agent's SUCCESSOR session would be the one cut. The teardown therefore runs only when the agent is left with no vigent token; otherwise it reports skipped. This is what keeps failed's retry advice usable: an already-revoked token with no live successor still evicts on a retried DELETE, so retrying is a real remedy and not a no-op. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — AgentCredentialForbidden
Setting or clearing a seat's widget-login password was refused (core#2404, ADR #126 addendum). coderole_forbidden: the caller is a dashboard login that is neither an account_admin nor the login linked to that seat (the route takes a dd_ key, an account_admin login or the seat's own login); the per-tenant knob agent_credential_admin_only (default on) turns this off. Any other 403 is the plain scope refusal (forbidden: a ddw_ or a public-site key).
json
{
"error": {
"code": "role_forbidden",
"message": "setting or clearing a seat's widget password takes a dd_ key, a dashboard login with the account_admin role, or the seat's own login"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Seat online + softphone bootstrap
http
POST /v1/agents/checkinSeat goes available. Optional device_mode (browser|external) persists on the seat and refines the campaign bridge hunt; omitted keeps the stored value (re-checkin stays idempotent). When SBC provisioning is enabled (KAMAILIO_RPC_URL; always on in prod) the response carries the FULL softphone bootstrap — an ephemeral SIP password minted per checkin, provisioned into Kamailio's agentcred htable (the ONLY place it exists; 24h autoexpire). Re-checkin reissues. With provisioning disabled the sip key is simply absent.
When the cell runs its own TURN relay, sip.turn carries an ephemeral relay credential with the same horizon (TurnIceServer, core#1076); absent otherwise.
Two failures AFTER the seat is opened answer 503 and revert it, so the seat is never left claiming an agent the caller cannot reach. Branch on code: sbc_unavailable when the SBC RPC fails — credentials that cannot register are never returned — and presence_unavailable when the durable seat-presence write fails (core#557).
WHICH seat is DERIVED FROM THE TOKEN, not from the body (core#1033, ADR #131). A dashboard login (ddu_) checks in the seat that users_agents.user_id links to that person. An agent browser token (ddw_) is 403 on this route by the widget allowlist (Dialer.API.WidgetAllowlist), as it is on the rest of /v1/agents. sip_extension is therefore OPTIONAL for a ddu_ session, and sending one that is not the token's own seat is 403 seat_mismatch — it is never honoured silently. A dd_ tenant API key carries no person, so it still names the seat by sip_extension (the account-wide principal may already create and delete any seat).
THE NOTICE GATE (core#2323, ADR #289). With the tenant's founder-governed flag agent_monitoring_ack_enforced on and at least one campaign that records (record_calls) and is not completed or archived, a seat that has not accepted the CURRENT recording-and-supervision notice (POST /v1/agent-monitoring-acknowledgements) gets 403 monitoring_ack_required BEFORE anything changes: the seat stays offline and no SIP credential is minted. The body carries error.notice_version, the version to accept (null when none is published, which also closes the gate: there is nothing to accept). Born off.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
sip_extension | string | no | REQUIRED with a dd_ tenant key; optional with a ddu_ session, where the seat comes from the token and a value that does not match it is 403. |
device_mode | browser · external | no | browser = kamailio-first WSS softphone; external = straight to the FS registrar. |
json
{
"sip_extension": "1001",
"device_mode": "external"
}Responses
200
Seat online (+ softphone bootstrap when SBC provisioning is on).
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no | |
sip | object (SipBootstrap) | no | Softphone bootstrap issued at checkin when SBC provisioning is enabled. The ephemeral password lives ONLY in Kamailio's agentcred htable — never in Postgres, never logged. Absent entirely when provisioning is disabled. |
sip.extension | string | no | Digest username (bare AOR). |
sip.password | string | no | Ephemeral, minted per checkin, NEVER persisted. |
sip.wss_url | string | no | SOFTPHONE_WSS_URL. |
sip.domain | string | no | SOFTPHONE_SIP_DOMAIN (From URI). |
sip.expires_at | string (date-time) | no | When this credential stops working. The htable autoexpire horizon (24h) CAPPED by the agent's widget session, when one STILL governs it: that session's absolute cap is at most 12h and the server evicts the credential the moment it lapses, so the earlier of the two is the only horizon a client can plan a renewal against. Always in the future — a session whose cap has already lapsed reports the flat horizon, never a past instant for a credential just issued. |
sip.turn | object (TurnIceServer) | no | The cell's own TURN relay (coturn) for agents whose network blocks UDP (MT-CTI-16, core#1076). A WebRTC RTCIceServer entry: the softphone appends it to the STUN list it reads from GET /v1/softphone/ice. It rides THIS authenticated answer precisely because that endpoint is unauthenticated — a relay credential there would reach every browser. Ephemeral, coturn use-auth-secret scheme: username is <unix expiry>:<agent id> and credential is Base64(HMAC-SHA1(shared secret, username)); nothing is stored per agent. It expires at the SAME instant as the SIP password (expires_at), so the re-checkin that renews one renews both. Absent when the cell has no TURN configured (SOFTPHONE_TURN_URLS / SOFTPHONE_TURN_SECRET unset): the softphone then negotiates without a relay, as it did before this key existed. |
sip.turn.urls | array of string | yes | turn: / turns: URLs (:3478 UDP/TCP, :5349 TLS). |
sip.turn.username | string | yes | <unix expiry>:<agent id>. |
sip.turn.credential | string | yes | Ephemeral; NEVER persisted. |
json
{
"agent": {
"id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"name": "Postman Seat",
"email": "[email protected]",
"role": "agent",
"status": "active",
"sip_extension": "1001",
"device_mode": "external",
"presence": "available",
"created_at": "2026-06-01T12:00:00Z",
"updated_at": "2026-07-05T10:00:00Z"
},
"sip": {
"extension": "1001",
"password": "ephemeral-example-not-real",
"wss_url": "wss://127.0.0.1:8443",
"domain": "sip.usa.dialerdigital.com",
"expires_at": "2026-07-06T10:00:00Z",
"turn": {
"urls": [
"turn:turn.example.test:3478?transport=udp",
"turns:turn.example.test:5349?transport=tcp"
],
"username": "1783332000:1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"credential": "ephemeral-example-not-real"
}
}
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — SeatFromToken
The seat could not be derived from the token, or the body contradicted it (core#1033, ADR #131). code seat_not_linked: this login has no active agent seat linked to it (users_agents.user_id), or the seat the token was bound to is no longer active — an administrator links the seat, the caller cannot pick one. code seat_mismatch: sip_extension named a seat that is not the token's own; the message deliberately does NOT reveal which seat is, so the refusal is not an oracle for colleagues' extensions. A derived seat that has NO sip_extension yet is 409 instead (the token and the seat are both right; what is missing is a roster field only an administrator can fill).
json
{
"error": {
"code": "seat_mismatch",
"message": "sip_extension does not match the seat this token owns"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}503 — CheckinUnavailable
Checkin refused fail-closed AFTER the seat was opened, so the seat was reverted and nothing durable is left claiming the agent is available. Retry. TWO causes, told apart by code (ErrorEnvelope says to branch on it, and a client that only knows one of them will silently do nothing for the other):
sbc_unavailable— the SBC RPC failed. Credentials that cannot register are never returned.presence_unavailable— the durable seat-presence write failed (core#557). It is not cosmetic: that column is whatSessionCapSweeperreads to tell this session apart from the one it is tearing down, so a checkin that answered 200 without it hands out a live SIP credential over a seat the sweeper may take back.
This response replaced the former SbcUnavailable, whose name promised the single cause it had when the checkin only had one.
sbc_unavailable
json
{
"error": {
"code": "sbc_unavailable",
"message": "softphone provisioning unavailable, retry"
}
}presence_unavailable
json
{
"error": {
"code": "presence_unavailable",
"message": "seat presence could not be recorded, retry"
}
}Seat offline (revokes the ephemeral SIP credential)
http
POST /v1/agents/checkoutOnly an available seat may leave (409 otherwise, fail-closed — a seat on a call cannot vanish). Revokes the ephemeral SIP credential (idempotent htable delete; the 24h autoexpire is the backstop for a missed revoke).
Same seat derivation as agentCheckin (core#1033, ADR #131): the seat comes from the token for a ddu_ session, so nobody can check a colleague out by naming their extension. A dd_ tenant key still names it; a ddw_ is 403 here by the widget allowlist.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
sip_extension | string | no | REQUIRED with a dd_ tenant key; optional with a ddu_ session, where the seat comes from the token and a value that does not match it is 403. |
json
{
"sip_extension": "1001"
}Responses
200
Seat offline.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — SeatFromToken
The seat could not be derived from the token, or the body contradicted it (core#1033, ADR #131). code seat_not_linked: this login has no active agent seat linked to it (users_agents.user_id), or the seat the token was bound to is no longer active — an administrator links the seat, the caller cannot pick one. code seat_mismatch: sip_extension named a seat that is not the token's own; the message deliberately does NOT reveal which seat is, so the refusal is not an oracle for colleagues' extensions. A derived seat that has NO sip_extension yet is 409 instead (the token and the seat are both right; what is missing is a roster field only an administrator can fill).
json
{
"error": {
"code": "seat_mismatch",
"message": "sip_extension does not match the seat this token owns"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Take an available seat out of rotation (paused)
http
POST /v1/agents/{id}/pauseMoves an available (or wrap_up) seat to paused with a reason from a CLOSED set; a paused seat is never handed a call and may still check out. Tenant scope only (a ddw_ agent token is 403 by the widget allowlist). 409 when the agent is not checked in, is reserved / on a call, or is already paused (fail-closed: nothing is coerced) — unless on_release: true is sent: on a ringing / on_call seat that ARMS the pause (ADR #101 D5, core#2542), the seat stays on its call and is answered with pause_armed: true; when the call is released (answered or not) the seat goes straight to paused with that reason, never through available. Arming again is idempotent (the last reason wins); POST /v1/agents/{id}/resume disarms it; a retirement armed by the session-cap sweep wins and discards it. on_release on a seat with no call behaves as the plain pause. The seat is in-memory state; whether the seat stays out of rotation across a restart depends on the tenant's agent_session_journal_enabled flag (ADR #101 D10).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
reason | manual · break · lunch · training · meeting | yes | |
on_release | boolean | no | Arm the pause for when the current call ends instead of refusing with 409 (ADR #101 D5). |
json
{
"reason": "lunch"
}Responses
200
Seat paused; agent.presence is read back from the seat.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Return a paused seat to rotation (available)
http
POST /v1/agents/{id}/resumeMoves a paused seat back to available, at the BACK of the reservation queue. Tenant scope only. 409 when the agent is not checked in or the seat is not paused. Leaving the pause is going Ready again, so it takes the same notice gate as the check-in: 403 monitoring_ack_required when the gate is on and the agent has not accepted the current notice (see POST /v1/agents/checkin).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
Seat available again; agent.presence is read back from the seat.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Finish after-call work (wrap_up -> available)
http
POST /v1/agents/{id}/wrap-upThe agent is done with after-call work: moves a wrap_up seat back to available, at the BACK of the reservation queue, and drops the reservation token the call left on the seat. Tenant scope only. 409 when the agent is not checked in or the seat is not in wrap_up.
There is NO verb that ENTERS wrap_up: the seat FSM enters it only on the hangup of an ANSWERED call, and only when the tenant's agent_wrap_up_seconds cap is above 0 (ADR #101 D5/D8). The cap is the other exit — the seat returns to available on its own when it expires — and POST /v1/agents/{id}/pause is the third (an agent may close its ACW with a pause).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
After-call work finished; agent.presence is read back from the seat.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Set a seat's presence by roster id (a FAÇADE over the seat FSM verbs)
http
PATCH /v1/agents/{id}/statusNOT a second presence model and NOT a state write: the requested status is translated to the verb the seat FSM already has, and the FSM decides. Whatever it refuses is 409 with the seat's current_state; nothing is coerced. Target → verb (the seat is read at call time):
status | from paused | from wrap_up | otherwise |
|---|---|---|---|
available | POST /v1/agents/{id}/resume | POST /v1/agents/{id}/wrap-up | 409 |
paused (needs reason) | 409 | POST /v1/agents/{id}/pause | POST /v1/agents/{id}/pause |
offline | POST /v1/agents/checkout | POST /v1/agents/checkout | POST /v1/agents/checkout |
available from offline is 409 on purpose: the check-in derives the seat from the token and mints an ephemeral SIP credential (POST /v1/agents/checkin), which a by-id call must not hand to whoever names the seat. ringing and on_call are never a target: the FSM enters them only on a call. Presence events on the live floor (agent.presence) are the SAME frames the verbs above emit, because the same verb runs.
Tenant scope only (a ddw_ agent token is 403 by the widget allowlist). available from paused takes the same notice gate as POST /v1/agents/{id}/resume (403 monitoring_ack_required). An id from another tenant is 404. offline additionally follows the author rule of the check-out: a ddu_ login WITH a linked seat can only set its OWN seat (403 otherwise).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
status | available · paused · offline | yes | |
reason | manual · break · lunch · training · meeting | no | Required when status is paused; ignored otherwise. |
json
{
"status": "paused",
"reason": "lunch"
}Responses
200
Status applied; agent.presence is read back from the seat.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.pause_reason | manual · break · lunch · training · meeting | null | no | Why the seat is paused; null unless presence is paused (core#2542). |
agent.state_since | string (date-time) | null | no | When the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542). |
agent.pause_armed | boolean | no | A pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409
The seat FSM does not admit that move from the seat's current state.
json
{
"error": {
"code": "conflict",
"message": "seat is on_call; the FSM has no transition to that status from here",
"current_state": "on_call"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}List planned shifts for the tenant
http
GET /v1/planned-shiftsWithout from/to: the 500 shifts with the earliest start_time, and truncated: true when there are more. Shifts are append-only, so past the 500th a newer shift is NOT in this list: ask for its window.
With from and to (RFC 3339, both or neither, from before to): every shift that overlaps [from, to), with no cap, ordered by start_time; truncated is false.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | no | Start of the window (RFC 3339). Requires to. |
to | query | string (date-time) | no | End of the window (RFC 3339), exclusive. Requires from. |
Responses
200
List of planned shifts
| Field | Type | Required | Description |
|---|---|---|---|
shifts | array of PlannedShift | yes | |
shifts[].id | string (uuid) | yes | |
shifts[].agent_id | string (uuid) | yes | |
shifts[].start_time | string (date-time) | yes | |
shifts[].end_time | string (date-time) | yes | |
shifts[].planned_state | available · ringing · on_call · wrap_up · paused | yes | |
truncated | boolean | yes | true when, without a window, there are more shifts than the 500 returned. Always false with from/to. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Create a planned shift
http
POST /v1/planned-shiftsPlans one shift for one agent (agent_id, start_time, end_time, optional planned_state). A missing agent_id is 400 invalid.
Who may name whom (ADR #133, ADR #134). The agent_id is bound by the PRINCIPAL, the same rule on every verb that reads one: a dd_ machine key names any agent of its tenant; a ddw_ only its own; a ddu_ login session linked to an agent seat acts as THAT seat — agent_id may be omitted (filled with the seat) or repeat it, and naming another agent is 403 forbidden; a ddu_ session with NO linked seat may name another agent only with users.role = account_admin — a member is 403 role_forbidden — once the tenant's catalog key ddu_author_role_enforced is on (born off: until then the seatless session keeps the dd_ contract on this verb).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string (uuid) | no | The agent the shift is planned for. Required for a dd_ key; a ddu_ session linked to a seat may omit it (its seat) and may not name another. |
start_time | string (date-time) | yes | |
end_time | string (date-time) | yes | |
planned_state | available · ringing · on_call · wrap_up · paused | no |
Responses
201
Planned shift created.
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}