Skip to content

Agents & seats ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

Seat roster, presence, softphone checkin/checkout.

Endpoints ​

MethodPathSummary
GET/v1/agents/{id}/monitoring-acknowledgementThe latest recording-and-supervision acceptance of one agent
GET/v1/agent/queueWhat the dialer has in front of the caller's OWN seat
GET/v1/agentsRoster + live seat presence
POST/v1/agentsCreate a seat
GET/v1/agents/occupancyDerived time by state and seat for an explicit window
GET/v1/agents/handle-timeHandle time per agent, crediting each agent only its legs of a transferred call
GET/v1/agents/{id}Fetch one agent + presence
PATCH/v1/agents/{id}Edit a seat or release it (status inactive)
GET/v1/agents/{id}/skillsThe routing skills of an agent
PUT/v1/agents/{id}/skillsReplace the routing skills of an agent
POST/v1/agents/{id}/credentialEnable or rotate an agent's widget-login password
DELETE/v1/agents/{id}/credentialDisable an agent's widget login
POST/v1/agents/checkinSeat online + softphone bootstrap
POST/v1/agents/checkoutSeat offline (revokes the ephemeral SIP credential)
POST/v1/agents/{id}/pauseTake an available seat out of rotation (paused)
POST/v1/agents/{id}/resumeReturn a paused seat to rotation (available)
POST/v1/agents/{id}/wrap-upFinish after-call work (wrap_up -> available)
PATCH/v1/agents/{id}/statusSet a seat's presence by roster id (a FAÇADE over the seat FSM verbs)
GET/v1/planned-shiftsList planned shifts for the tenant
POST/v1/planned-shiftsCreate a planned shift

The latest recording-and-supervision acceptance of one agent ​

http
GET /v1/agents/{id}/monitoring-acknowledgement

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The agent's monitoring-notice state.

FieldTypeRequiredDescription
monitoringobject (AgentMonitoringStatus)yesAn agent's standing against the current notice.
monitoring.notice_versioninteger | nullyesThe current version; null while none is in force.
monitoring.currentbooleanyesTrue only when the agent has accepted the CURRENT version.
monitoring.acknowledgementAgentMonitoringAck | nullyesThe 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/queue

A 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 as GET /v1/agents.
  • live_call: the seat's in-flight call (CDR), or null.
  • callbacks: pending callbacks the agent SCHEDULED, soonest first (callbacks.agent_id is 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's inbound_queue_stream_enabled flag 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.

FieldTypeRequiredDescription
queueobject (AgentQueue)yesWhat the dialer has in front of one seat (GET /v1/agent/queue). Ids, timestamps and priority only — no PII.
queue.agent_idstring (uuid)yes
queue.presenceoffline · available · ringing · on_call · wrap_up · pausedyes
queue.live_callobject,nullyes
queue.live_call.attempt_idstring (uuid)yes
queue.live_call.campaign_idstring,null (uuid)yes
queue.live_call.debt_idstring,null (uuid)yes
queue.live_call.started_atstring (date-time)yes
queue.live_call.answered_atstring,null (date-time)yes
queue.callbacksarray of objectyes
queue.callbacks[].idstring (uuid)yes
queue.callbacks[].debt_idstring,null (uuid)yes
queue.callbacks[].campaign_idstring,null (uuid)yes
queue.callbacks[].scheduled_atstring (date-time)yes
queue.callbacks[].priorityintegeryes
queue.inbound_offersarray of objectyes
queue.inbound_offers[].queue_idstring (uuid)yes
queue.inbound_offers[].admission_idstring (uuid)yes
queue.inbound_offers[].call_idstring (uuid)yes
queue.inbound_offers[].enqueued_atstring (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/agents

Durable roster merged with live presence (offline|available|ringing|on_call).

Responses

200 ​

All agents with presence.

FieldTypeRequiredDescription
agentsarray of Agentyes
agents[].idstring (uuid)no
agents[].tenant_idstring (uuid)no
agents[].namestringno
agents[].emailstringno
agents[].roleagent · supervisor · adminno
agents[].statusactive · inactivenoRoster flag — NOT presence.
agents[].sip_extensionstring | nullno
agents[].device_modebrowser · externalno
agents[].login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agents[].presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agents[].state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agents[].pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agents[].created_atstring (date-time)no
agents[].updated_atstring (date-time)no
truncatedbooleanyestrue 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/agents

Request body (JSON, required)

FieldTypeRequiredDescription
namestringyes
emailstring (email)yes
roleagent · supervisor · adminno
sip_extensionstringnoThe 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.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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 the max_seats active seats of the tier in force (tier). A seat over the cap is never billed as an extra: free a seat (an inactive one 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, so active_seats can be above max_seats; only new seats are refused until it is back under. enterprise has 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/occupancy

Tenant-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

NameInTypeRequiredDescription
fromquerystring (date-time)yesInclusive start of the interval-entry window (RFC 3339).
toquerystring (date-time)yesExclusive end of the interval-entry window (RFC 3339); must be after from.
agent_idquerystring (uuid)noOptional seat filter, always inside the tenant resolved from the bearer.

Responses

200 ​

The occupancy derivation for seats with evidence in the window.

FieldTypeRequiredDescription
windowobjectyes
window.fromstring (date-time)yes
window.tostring (date-time)yes
agentsobjectyesMap 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-time

Tenant-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

NameInTypeRequiredDescription
fromquerystring (date-time)yesInclusive start of the call-start window (RFC 3339).
toquerystring (date-time)yesExclusive end of the call-start window (RFC 3339); must be after from.
agent_idquerystring (uuid)noOptional 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.

FieldTypeRequiredDescription
definitionv1yes
fromstring (date-time)yes
tostring (date-time)yes
agentsarray of objectyes
agents[].agent_idstring (uuid)yes
agents[].callsintegeryesCalls in which the agent held control for a measurable time.
agents[].control_msintegeryes
agents[].consult_msintegeryes
agents[].occupied_msintegeryes
agents[].aht_msinteger | nullyes
excludedobjectyes
excluded.open_legsintegeryes

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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The agent.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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 (inactive holds no seat), the seat's live ddw_ tokens are revoked, it is checked out and its SIP credential is evicted (sip_teardown: ok | skipped | failed). The row is never deleted; deactivated_at is 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) or 422 unpriceable_tier.
  • 409 last_admin_seat — the seat is the only active admin seat 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
emailstringno
roleagent · supervisor · adminno
sip_extensionstringno
statusactive · inactiveno
json
{
  "status": "inactive"
}

Responses

200 ​

The seat after the edit.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (date-time)no
sip_teardownok · skipped · failednoPresent 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}/skills

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The agent's skills.

FieldTypeRequiredDescription
agent_idstring (uuid)yes
skillsarray of stringyes

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}/skills

Declares 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
skillsarray of stringyes
json
{
  "skills": [
    "idioma_es",
    "cobranza_tardia"
  ]
}

Responses

200 ​

The agent's skills after the write, sorted.

FieldTypeRequiredDescription
agent_idstring (uuid)yes
skillsarray of stringyes

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}/credential

Sets (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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
passwordstring (password)yes

Responses

200 ​

The agent (now login_enabled true), plus the SIP teardown outcome.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (date-time)no
sip_teardownok · skipped · failedyesOutcome 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}/credential

Clears 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The agent (now login_enabled false), plus the SIP teardown outcome.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (date-time)no
sip_teardownok · skipped · failedyesOutcome 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/checkin

Seat 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)

FieldTypeRequiredDescription
sip_extensionstringnoREQUIRED 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_modebrowser · externalnobrowser = 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).

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (date-time)no
sipobject (SipBootstrap)noSoftphone 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.extensionstringnoDigest username (bare AOR).
sip.passwordstringnoEphemeral, minted per checkin, NEVER persisted.
sip.wss_urlstringnoSOFTPHONE_WSS_URL.
sip.domainstringnoSOFTPHONE_SIP_DOMAIN (From URI).
sip.expires_atstring (date-time)noWhen 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.turnobject (TurnIceServer)noThe 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.urlsarray of stringyesturn: / turns: URLs (:3478 UDP/TCP, :5349 TLS).
sip.turn.usernamestringyes<unix expiry>:<agent id>.
sip.turn.credentialstringyesEphemeral; 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 what SessionCapSweeper reads 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/checkout

Only 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)

FieldTypeRequiredDescription
sip_extensionstringnoREQUIRED 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.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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}/pause

Moves 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
reasonmanual · break · lunch · training · meetingyes
on_releasebooleannoArm 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.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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}/resume

Moves 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

Seat available again; agent.presence is read back from the seat.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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-up

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

After-call work finished; agent.presence is read back from the seat.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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}/status

NOT 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):

statusfrom pausedfrom wrap_upotherwise
availablePOST /v1/agents/{id}/resumePOST /v1/agents/{id}/wrap-up409
paused (needs reason)409POST /v1/agents/{id}/pausePOST /v1/agents/{id}/pause
offlinePOST /v1/agents/checkoutPOST /v1/agents/checkoutPOST /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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
statusavailable · paused · offlineyes
reasonmanual · break · lunch · training · meetingnoRequired when status is paused; ignored otherwise.
json
{
  "status": "paused",
  "reason": "lunch"
}

Responses

200 ​

Status applied; agent.presence is read back from the seat.

FieldTypeRequiredDescription
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (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-shifts

Without 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

NameInTypeRequiredDescription
fromquerystring (date-time)noStart of the window (RFC 3339). Requires to.
toquerystring (date-time)noEnd of the window (RFC 3339), exclusive. Requires from.

Responses

200 ​

List of planned shifts

FieldTypeRequiredDescription
shiftsarray of PlannedShiftyes
shifts[].idstring (uuid)yes
shifts[].agent_idstring (uuid)yes
shifts[].start_timestring (date-time)yes
shifts[].end_timestring (date-time)yes
shifts[].planned_stateavailable · ringing · on_call · wrap_up · pausedyes
truncatedbooleanyestrue 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-shifts

Plans 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)

FieldTypeRequiredDescription
agent_idstring (uuid)noThe 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_timestring (date-time)yes
end_timestring (date-time)yes
planned_stateavailable · ringing · on_call · wrap_up · pausedno

Responses

201 ​

Planned shift created.

FieldTypeRequiredDescription
idstring (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"
  }
}

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.