# Authentication (https://docs.smao.ai/docs/authentication) The two REST APIs use independent key types - a Public API key never works against Distributor API v1 and vice versa. Both are sent the same way: `Authorization: Bearer `. Newly issued Public API keys are \~43-character base64url strings with no prefix; legacy `smao_`-prefixed Public API keys remain valid. ## MCP Connector - OAuth or organization API key [#mcp-connector---oauth-or-organization-api-key] For an interactive AI client, add `https://api.smao.ai/mcp` as a remote MCP connector and use OAuth. The client discovers SMAO's authorization server, opens the browser login and consent flow, and binds the connection to the organization you select. Disconnect and reconnect to switch organizations. Clients that support custom HTTP headers can instead send a Public API organization key as `Authorization: Bearer `. Both methods apply the current organization role and plan entitlements on every request. See the [MCP setup and access rules](/docs/mcp) for client examples, scopes, and revocation. ## Public API - organization API keys [#public-api---organization-api-keys] Create keys yourself in the dashboard under **Settings → API Keys → New key**. Each key gets a **Role** that scopes what it can do - pick the narrowest role that covers your use case. Keys belong to one organization; every request is scoped to that organization's data. ```bash curl https://api.smao.ai/api/v1/ping \ -H "Authorization: Bearer YOUR_API_KEY" ``` A missing or invalid key returns `401`: ```json { "error": { "code": "unauthorized", "message": "Missing API key" } } ``` A valid key whose role lacks permission for the endpoint returns `403` with `error.code: "forbidden"`. If the key's organization is blocked, every request returns `403` with `error.code: "organization_blocked"`. See [Errors](/docs/errors) for the full envelope. The raw key is shown once, at creation. Store it in a secret manager - it cannot be retrieved later. Key handling: * **One key per integration.** Separate keys let you revoke one consumer without breaking the others. * **Rotate by overlap.** Create a new key, deploy it, then delete the old one. Both work in parallel in between, so rotation needs no downtime. * **Revoke immediately on leak.** Deleting a key invalidates it at once. ## Distributor API v1 - distributor keys [#distributor-api-v1---distributor-keys] Distributor keys are issued by SMAO during onboarding - there is no self-service issuance. The raw key is shown exactly once at creation; the server stores a SHA-256 hash plus the first 12 characters as an identification prefix - never the full key. Every v1 key carries these fixed permissions: * `organizations:read` * `organizations:write` * `usage:read` * `partners:read` * `partners:write` Permission sets are immutable after issuance. Keys expire, by default after **365 days**. Request a replacement key from SMAO before expiry and switch over * old and new keys work in parallel until the old one expires or is revoked, so rotation needs no downtime. ```bash curl https://api.smao.ai/api/distributor/v1/partners \ -H "Authorization: Bearer YOUR_DISTRIBUTOR_KEY" ``` Auth failures return `401` in the Distributor error envelope, with an `errorCode` telling you why: ```json { "status": "error", "errorCode": "INVALID_API_KEY", "message": "Invalid or revoked API key." } ``` | `errorCode` | Meaning | | ------------------------- | ----------------------------------------------------------- | | `MISSING_API_KEY` | No `Authorization: Bearer` header, or an empty key. | | `INVALID_API_KEY` | Key unknown or revoked. | | `API_KEY_EXPIRED` | Key past its expiry date - request a replacement from SMAO. | | `API_VERSION_NOT_ALLOWED` | Key metadata does not authorize Distributor API v1. | Only `GET /health` is public. Every other Distributor endpoint requires a valid v1 key and its documented permission. Continue with the [Distributor v1 quickstart](/docs/distributor-v1/quickstart). ## REST API comparison [#rest-api-comparison] | | Public API | Distributor API v1 | | ----------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Issuance | Self-service: dashboard **Settings → API Keys** | By SMAO during onboarding | | Scoping | One organization | All organizations managed by the distributor | | Permissions | Role-based, chosen per key | Five fixed v1 scopes | | Expiry | None | 365 days by default | | Rotation | Create new key, deploy, delete old | Request replacement from SMAO; parallel until old key expires or is revoked | | Rate limits | Per key defaults: read 1000/min, write 200/min, `POST /calls` 60/hour | Per key: 60 req/min default (configurable per key) | | 401 shape | `{ "error": { "code": "unauthorized", ... } }` | `{ "status": "error", "errorCode": "...", "message": "..." }` | For the Public API, matched operations that pass authentication and the feature gate return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` after the limiter succeeds. Auth failures, feature-gate failures, unmatched routes, and limiter-storage failures do not carry the full header set. Details in [Errors](/docs/errors). # Errors & Rate Limits (https://docs.smao.ai/docs/errors) The **Public API** wraps errors in `{"error": {"code", "message"}}` with lowercase `snake_case` codes. The **Distributor API** returns a flat `{"status": "error", "errorCode", "message"}` object with `SCREAMING_SNAKE_CASE` codes. Parsers written for one will silently fail on the other. ## Public API errors [#public-api-errors] Every non-2xx response from `/api/v1` has this shape: ```json { "error": { "code": "not_found", "message": "Not found" } } ``` | Code | HTTP | Meaning | Retry? | | ------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `validation_failed` | 400 | Request body or query parameters failed validation | No - fix the request. Check `details` | | `unauthorized` | 401 | Missing or invalid API key | No - fix the key | | `subscription_required` | 402 | A feature required by this endpoint is not enabled for the organization | No - upgrade the plan | | `forbidden` | 403 | The API key's role lacks permission for this action | No - use a key with a broader role | | `organization_blocked` | 403 | The API key's organization is blocked; every request with its keys is refused | No - contact support | | `not_found` | 404 | Resource does not exist (or belongs to another organization) | No - check the ID | | `conflict` | 409 | Resource is in use or the operation would violate an invariant | No - resolve the conflict first | | `outbound_target_blocked` | 409 | The exact outbound target is blocked for the selected assistant. Manage lists and assignments in Dashboard → Settings → Blocked callers; configuration is not exposed through the Public API | No - choose another target or change the Dashboard policy | | `rate_limit_exceeded` | 429 | Rate limit exceeded, an outbound assistant is in cooldown, or the limiter is unavailable | Yes - use `Retry-After` when present; otherwise back off | | `internal_error` | 500 | Unexpected server error; the operation did not complete | Reads: yes. Mutations: check resource state first | ### Validation details [#validation-details] `validation_failed` responses carry an optional `details` array. Each entry has a `path` (array of field names and array indices) and a `message`: ```json { "error": { "code": "validation_failed", "message": "Validation failed", "details": [ { "path": ["first_name"], "message": "\"first_name\" is required" }, { "path": ["phone_numbers", 0], "message": "\"phone_numbers[0]\" must be a string" } ] } } ``` Per-endpoint error responses are listed in the [Public API reference](/docs/public). ## MCP errors [#mcp-errors] Authentication and entitlement failures happen before MCP dispatch and are normal HTTP errors. `401` means the credential is missing or invalid; OAuth clients use its `WWW-Authenticate` metadata to start authorization. `402 subscription_required` means the organization is not entitled - reconnecting OAuth cannot fix it. `403` means the user or organization lifecycle blocks the request (a blocked organization gets `ORGANIZATION_BLOCKED` for OAuth and API-key credentials alike), `429` means retry after backoff, and `503` means OAuth configuration or the current organization authorization context cannot be loaded. Once dispatch starts, tool validation and service errors are MCP tool results with `isError: true` and the Public API `{ "error": { "code", "message", "details?" } }` body. Branch on `code`, not localized `message`. See [MCP setup and access rules](/docs/mcp). ## Distributor API errors [#distributor-api-errors] Every non-2xx response from `/api/distributor/v1` has this shape: ```json { "status": "error", "errorCode": "RATE_LIMITED", "message": "Too many requests for this API key." } ``` Match on `errorCode`, not on `message` - messages can change. Common integration-relevant codes: | errorCode | HTTP | Meaning / action | | ---------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------- | | `MISSING_API_KEY`, `INVALID_API_KEY` | 401 | No/bad bearer key. Fix the key | | `API_KEY_EXPIRED` | 401 | Key past its expiry (default 365 days). Request a replacement from SMAO | | `API_VERSION_NOT_ALLOWED` | 403 | The key metadata does not authorize Distributor API v1. Request a valid v1 key | | `MISSING_PERMISSION` | 403 | Key lacks the required permission. Permission sets are immutable - you need a different key | | `VALIDATION_ERROR` | 400 | Bad request body or parameters. Fix and resend | | `INVALID_ID`, `INVALID_DATE_RANGE`, `DATE_RANGE_TOO_LARGE` | 400 | An identifier or date window is invalid. Fix and resend | | `NOT_FOUND` | 404 | Resource not found in this distributor's scope | | `RATE_LIMITED` | 429 | Rate limit exceeded. Wait `Retry-After` seconds, then retry | | `RATE_LIMIT_UNAVAILABLE` | 503 | Rate-limit store unavailable (fail-closed). Retry with backoff | | `INTERNAL_ERROR` | 500 | Unexpected server error. There is no idempotency-key header - check state before retrying a mutation | ### Partner and organization codes [#partner-and-organization-codes] | errorCode | HTTP | Meaning / action | | -------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | | `DUPLICATE_EXTERNAL_PARTNER_ID` | 409 | `externalPartnerId` already exists. Fetch and reconcile the existing partner | | `DUPLICATE_EXTERNAL_CUSTOMER_ID` | 409 | `externalCustomerId` already exists. Fetch and reconcile the existing customer | | `PARTNER_NOT_FOUND` | 404 | The requested active v1 partner was not found in this distributor's scope | | `PARTNER_HAS_ORGANIZATIONS` | 409 | The partner still has linked organizations, including its internal MSP organization; v1 does not cascade-delete them | | `INVALID_PLAN`, `PLAN_NOT_FOUND` | 400/404 | The requested assignable plan is invalid, or no subscription exists for billing | | `ORG_CREATION_FAILED` | 500 | The partner's internal organization could not be provisioned | The full enums and per-endpoint responses are in the [Distributor v1 reference](/docs/distributor-v1). ## Rate limits [#rate-limits] The REST APIs limit per API key. For the Public API, matched operations return the three headers after authentication, the feature gate, and the limiter succeed. Authentication failures, the `402` feature gate, unmatched routes, and a limiter-storage failure do not carry the complete header set: ```bash curl -sD - -o /dev/null "https://api.smao.ai/api/v1/assistants" \ -H "Authorization: Bearer $SMAO_API_KEY" ``` ``` X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 997 X-RateLimit-Reset: 1776902940 ``` `X-RateLimit-Reset` is a Unix timestamp (seconds) of the window reset. | API | Bucket | Limit | | ----------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | Public | `read` - authenticated documented GET operations | 1,000 / min default | | Public | `write` - authenticated documented POST / PATCH / DELETE operations except `/calls` | 200 / min default | | Public | `outbound_trigger` - POST `/calls` | 60 / hour default, plus one outbound call per assistant per 60 seconds | | MCP | read tools, per organization and OAuth user or API key | 1,000 / min | | MCP | write tools, per organization and OAuth user or API key | 200 / min | | Distributor | all authenticated endpoints (`/health` is unlimited) | 60 / min (default; per-key configurable) | MCP counts every JSON-RPC message. A batch of N messages costs N units and is charged to the write bucket when any message calls a write tool. A batch that does not fit is rejected whole with `429`, and none of its messages run. Distributor responses that pass authentication include `Retry-After` (seconds) for the current rate-limit window, even before the bucket is exhausted. On `429`, sleep that long before retrying; `X-RateLimit-Reset` carries the same information as an absolute Unix timestamp. A Public API `429` caused by the per-assistant outbound cooldown or an unavailable limiter store does not include `Retry-After`; use exponential backoff, with at least 60 seconds for the assistant cooldown. All three surfaces fail closed. If the rate-limit store cannot be reached, the Public API rejects with 429 `rate_limit_exceeded` without rate-limit headers, the Distributor API rejects with 503 `RATE_LIMIT_UNAVAILABLE`, and MCP rejects with a 429 JSON-RPC error plus `Retry-After`. Retry with exponential backoff; do not treat it as a hard failure. # Getting Started (https://docs.smao.ai/docs/getting-started) ## Base URL [#base-url] Use `https://api.smao.ai` as the base URL. Append the base path of the API you are calling: | API | Base path | Example | | ------------------------------------------ | --------------------- | ------------------------------------------------- | | [MCP Connector](/docs/mcp) | `/mcp` | `https://api.smao.ai/mcp` | | Public API | `/api/v1` | `https://api.smao.ai/api/v1/ping` | | [Distributor API v1](/docs/distributor-v1) | `/api/distributor/v1` | `https://api.smao.ai/api/distributor/v1/partners` | ## Choosing the right integration [#choosing-the-right-integration] SMAO exposes one AI connector and two separate REST APIs. For connecting an AI client directly to a SMAO organization. The client discovers the available tools and can authenticate interactively with OAuth or with an organization API key. For SMAO customers automating their own organization: manage assistants and their configuration (prompts, knowledge, glossary, pronunciation, forwardings, calendars, tools), contacts, calls and recordings, website chats, and usage statistics. Keys are self-service - create them in the dashboard. For marketplaces and distribution partners provisioning SMAO on behalf of their customers: create partners and customer organizations, assign plans, and read usage and billing. Keys are issued by SMAO during partner onboarding. If an AI client should operate SMAO for you, use MCP. If your own application needs direct HTTP integration, use the Public API. If you resell SMAO and need to create organizations for third parties, use Distributor API v1. Both REST APIs authenticate with a bearer token, but the keys are not interchangeable. MCP accepts either Public API organization keys or OAuth. See [Authentication](/docs/authentication) for how each credential type works. ## Making your first request [#making-your-first-request] Get a Public API key from the dashboard under *Settings → API Keys → New key*, picking a role that matches the scope you need. Then call [`GET /ping`](/docs/public/meta/getPing) - it confirms the API is reachable and your key is valid: ```bash curl https://api.smao.ai/api/v1/ping \ -H "Authorization: Bearer YOUR_API_KEY" ``` Success looks like: ```json { "data": { "ok": true, "now": "2026-04-23T00:00:00.000Z" } } ``` A `401` with `{ "error": { "code": "unauthorized", ... } }` means the key is missing or invalid - check the header and that you are using a Public API key, not a Distributor key. Matched Public API operations that pass authentication and the feature gate carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. See [Errors](/docs/errors) for exceptions and retry behavior. Distributor API v1 has no `/ping` endpoint and a different error format. Start at the [Distributor v1 quickstart](/docs/distributor-v1/quickstart) instead. From here, continue with the [Public API overview](/docs/public) or jump straight to the [Public API quickstart](/docs/public/quickstart). # SMAO Developer Platform (https://docs.smao.ai/docs) SMAO exposes an AI connector and two REST APIs. Pick your entry point: New here? Start with [Getting Started](/docs/getting-started), then [Authentication](/docs/authentication) and [Errors & Rate Limits](/docs/errors). ## Trying requests [#trying-requests] Every endpoint reference page includes an interactive playground. The raw OpenAPI specifications are also available for your own tooling: * Public API spec: [**SMAO\_API\_URL**/api/v1/openapi.yaml](https://api.smao.ai/api/v1/openapi.yaml) (tenant-branded per host) * Distributor API v1 spec: [**SMAO\_API\_URL**/api/distributor-docs/v1/openapi.yaml](https://api.smao.ai/api/distributor-docs/v1/openapi.yaml) The bare `/api/distributor-docs/openapi.yaml` path serves the same Distributor v1 contract for integrations that saved that URL. # Distributor API v1 (https://docs.smao.ai/docs/distributor-v1) Distributor API v1 is SMAO's maintained Distributor API. Synaxon and other distribution partners use it at `/api/distributor/v1` to manage a partner-based reseller hierarchy and retrieve data for external billing. ## The provisioning model [#the-provisioning-model] The API manages a three-tier hierarchy with an explicit partner resource: ```text Distributor -> Partner (MSP) -> Customer organization ``` * Partners are created through `/partners`. Each partner gets its own internal SMAO organization, returned as `partner.organizationId`, and can onboard customers from the dashboard. * Customer organizations use `partnerId` to link to their MSP and `externalCustomerId` as the distributor's reconciliation identifier. * Plans are assigned directly per organization through [`PATCH /organizations/{organizationId}/plan`](/docs/distributor-v1/billing/updateDistributorOrganizationPlan). ## Authentication and behavior [#authentication-and-behavior] Send the SMAO-issued v1 key as `Authorization: Bearer `. Keys include the partner, organization, and usage permissions documented for each endpoint. Invalid version metadata returns `403 API_VERSION_NOT_ALLOWED`. Errors use the flat `{ "status": "error", "errorCode", "message" }` envelope. The requests-per-minute limit is configured per key and defaults to 60. See [Authentication](/docs/authentication) and [Errors](/docs/errors). Integration is polling-based; there are no Distributor webhooks. ## Operational boundaries [#operational-boundaries] * `/organizations` manages end-customer organizations linked to a partner. The MSP's internal organization is identified by `partner.organizationId` and is not a customer resource. * Partner creation attempts to start the MSP organization on a free trial when a trial plan is available. Trial setup is best-effort. The trial is capped by call minutes, not by time; to keep the MSP's own workspace in use beyond it, assign a paid plan with `PATCH /partners/{partnerId}/plan` - the MSP organization cannot be reached through `/organizations/{organizationId}/plan`. * When `contactEmail` is supplied, invitation creation and email delivery are best-effort. Reconcile dashboard access separately after provisioning. * A partner with an internal MSP organization cannot be deleted through `DELETE /partners/{partnerId}` while that organization exists. There is no cascade-delete operation. ## API reference [#api-reference] Start with the [v1 quickstart](/docs/distributor-v1/quickstart). The reference below is generated from the v1 OpenAPI spec. The raw spec is served at [**SMAO\_API\_URL**/api/distributor-docs/v1/openapi.yaml](https://api.smao.ai/api/distributor-docs/v1/openapi.yaml). The bare `/api/distributor-docs/openapi.yaml` path serves the same contract for saved integrations. # v1 Quickstart (https://docs.smao.ai/docs/distributor-v1/quickstart) This is the partner-based workflow for Synaxon and Distributor API v1 integrations. Base URL: `https://api.smao.ai/api/distributor/v1`. ## 1. Configure the v1 key [#1-configure-the-v1-key] Distributor keys are issued by SMAO and shown only once. Store the raw key in a secret manager. A v1 key is authorized for v1 only. ```bash export SMAO_DISTRIBUTOR_KEY="your-v1-distributor-key" export BASE="https://api.smao.ai/api/distributor/v1" ``` `GET /health` is public. All other endpoints require the Bearer token: ```bash curl "$BASE/health" curl "$BASE/plans" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" ``` Choose a plan `slug` and `commitment` from `/plans` for the customer request below. ## 2. Create the partner (MSP) [#2-create-the-partner-msp] ```bash curl -X POST "$BASE/partners" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "MSP Hamburg GmbH", "externalPartnerId": "SYN-MSP-001", "contactEmail": "admin@msp.example", "contactName": "Ada Admin", "timezone": "Europe/Berlin" }' ``` Save both IDs from the response: * `partner.id` is the `partnerId` used for customer organizations. * `partner.organizationId` is the MSP's own internal SMAO organization. The internal organization is part of successful partner provisioning. Trial setup, invitation creation, and invitation email delivery are best-effort; do not use email receipt as the provisioning acknowledgement. The trial is capped by call minutes, not by time. To keep the MSP's own workspace in use beyond it, assign a paid plan to the partner - the MSP organization is not reachable through the `/organizations` endpoints: ```bash curl -X PATCH "$BASE/partners/665abc123def456789012345/plan" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" \ -H "Content-Type: application/json" \ -d '{ "plan": { "slug": "advanced", "commitment": "monthly" } }' ``` ## 3. Create a customer organization [#3-create-a-customer-organization] ```bash curl -X POST "$BASE/organizations" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" \ -H "Content-Type: application/json" \ -d '{ "organization": { "name": "Customer Hamburg GmbH", "industry": "Manufacturing", "timezone": "Europe/Berlin" }, "owner": { "email": "owner@customer.example", "firstName": "Chris", "lastName": "Customer" }, "externalCustomerId": "SYN-CUSTOMER-4711", "externalContractId": "SYN-CONTRACT-4711", "partnerId": "665abc123def456789012345", "plan": { "slug": "business", "commitment": "monthly" } }' ``` Replace `partnerId` and the plan values with those returned by your calls. A successful response includes the customer organization and its owner invitation record. Email transport is best-effort. `externalPartnerId` and `externalCustomerId` are reconciliation keys. If a retry returns a duplicate-ID conflict, fetch the existing record instead of creating a different identifier. ## 4. Read usage and billing [#4-read-usage-and-billing] Usage query windows use inclusive `YYYY-MM-DD` dates and may cover up to 365 days. For compatibility, the single-organization response serializes its `period` boundaries as ISO UTC date-times; aggregate responses echo the query dates. ```bash curl "$BASE/organizations/69c175970f3e28310926a1aa/usage?from=2026-07-01&to=2026-07-31" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" curl "$BASE/partners/665abc123def456789012345/usage?from=2026-07-01&to=2026-07-31" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" ``` Billing summaries are limited to 31 days and price usage against the current assigned plan: ```bash curl "$BASE/organizations/69c175970f3e28310926a1aa/billing-summary?from=2026-07-01&to=2026-07-31" \ -H "Authorization: Bearer $SMAO_DISTRIBUTOR_KEY" ``` Monetary values are in the smallest unit of the returned currency (for example, cents for EUR). The API is polling-only; there are no Distributor webhooks. Do not run create, update, plan-assignment, or delete examples against live customer data as connectivity tests. Use `/health`, `/plans`, and read endpoints for deployment verification. # MCP Connector (https://docs.smao.ai/docs/mcp) The SMAO MCP connector lets ChatGPT, Claude, Cursor, and other Model Context Protocol clients read and configure a SMAO organization. It is an AI-friendly adapter over the existing Public API business services: it uses the same tenant scope, validators, role permissions, plan entitlements, and response shapes. There are no resource-specific MCP URLs. Give the client one remote connector URL; it runs MCP discovery, lists the tools available to the credential, and calls them through the same endpoint. Connector URL: `https://api.smao.ai/mcp` The transport is stateless JSON-RPC over `POST /mcp`. `GET /mcp` and `DELETE /mcp` are not application endpoints. OAuth-capable clients discover the authorization server through the published `/.well-known/*` metadata; you do not configure those URLs manually. ## Connect with OAuth [#connect-with-oauth] OAuth is the recommended setup for interactive AI clients: 1. Add a custom or remote MCP connector and enter the connector URL above. 2. Follow the browser sign-in link. A current dashboard session can continue directly to consent; otherwise sign in first. 3. Select the organization, review the requested scopes, and approve. 4. Return to the client and let it discover the available SMAO tools. The connector supports `smao.read` and `smao.write`. The access token is bound to the selected organization, but SMAO reloads the user's current lifecycle, membership, and role on every request. Role edits, membership removal, and account deletion therefore take effect without waiting for token expiry. Disconnect and reconnect to select a different organization. In ChatGPT developer mode, Claude.ai, and Cursor, use their custom/remote MCP connector screen and the same URL. Exact menu labels can change; no client-specific SMAO endpoint is required. ## Connect with an API key [#connect-with-an-api-key] For clients that support custom HTTP headers, create a key under **Settings → API Keys → New key**, choose the narrowest suitable role, and configure: ```text Authorization: Bearer ``` Claude Code example: ```bash claude mcp add --transport http smao https://api.smao.ai/mcp \ --header "Authorization: Bearer " ``` The key is the same organization key accepted by the Public API. It is shown once: keep it in a secret manager and never paste it into a chat prompt. Delete the key in the dashboard to revoke the connection. ## Access rules [#access-rules] Every request must pass all applicable checks: * The organization must have global `api_access`. Managed white-label and distributor-managed tenants are enforced from their subscription snapshot. The default hosted SMAO instance currently follows its open-subscription policy; custom/external billing and active Enterprise subscriptions are treated as unlimited. * OAuth is limited by the granted read/write scopes and the user's current organization role. API keys are limited by the role selected for the key. * Tool-specific product features still apply. For example, forwarding tools require call forwarding, prompt tools require custom prompts, webhook tools require tools, and website-chat tools require widgets. * `tools/list` hides tools the credential cannot use, and every call repeats the checks. MCP exposes no delete tools. The default **Member** role sees only `list_calls`, `get_call`, and `get_statistics`. Configuring assistants, knowledge, prompts, groups, glossary, pronunciation, forwardings, or webhook tools requires **Admin** or a custom role with the corresponding permissions. Reading website chats needs a role that can view Inbox conversations, such as **Admin**. The connector exposes tools for assistants, voices and purchased phone numbers, prompts, knowledge, groups, glossary, pronunciation, forwardings, webhook tools, call logs, website chats, and statistics. Inputs use the same `snake_case` fields as the [Public API reference](/docs/public), and unknown fields are rejected. Knowledge writes trigger asynchronous indexing; poll the returned item's `sync_status` instead of assuming it is immediately searchable. Call logs are read-only. `get_call` returns the same call detail as the Public API, including `mailbox_detection` for an outbound call that was started with mailbox detection. The connector cannot start calls. Website chats are read-only too. `list_chats` and `get_chat` return the chats from your website widget and the messages the visitor saw, as the Public API does. Visitor messages are text anyone on your website can type, so AI clients are told to treat them as information, never as instructions. Replying and taking over stay in the dashboard Inbox. Every assistant created through the connector holds a phone number until you delete it in the dashboard, because the connector has no delete tools. To give a new assistant one of your organization's purchased numbers, pass an `id` from `list_phone_numbers` as `phone_number_id`; otherwise the platform assigns one. Webhook tool credentials are write-only: SMAO stores them encrypted and never returns them. A credential you give an AI client stays in that client's conversation history, so you can enter it in the dashboard instead. Never put credentials in webhook headers: headers are stored and returned in plaintext. Changing the URL of a webhook that has a stored credential requires entering the credential again, so a credential never moves to a new URL on its own. ## Troubleshooting and revocation [#troubleshooting-and-revocation] | Status | Meaning | Action | | --------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | `401` | Missing, expired, or invalid credential | OAuth clients should authorize; API-key clients should replace the key | | `402 subscription_required` | The organization lacks `api_access` | Enable the entitlement; do not reconnect OAuth | | `403` | The current user or organization lifecycle blocks the connection | Resolve the account state | | `429` | MCP rate limit exceeded | Honor `Retry-After` when present and back off | | `503` | OAuth is unavailable or the current organization role context cannot be loaded | Retry; API-key access remains available when only OAuth is unconfigured | OAuth disconnect/revocation invalidates the grant and refresh token. API-key connections remain active until the key is deleted or the organization is blocked. See [Errors & Rate Limits](/docs/errors) for response shapes and limits. # Public API (https://docs.smao.ai/docs/public) The Public API gives you programmatic access to the resources you manage in the dashboard, scoped to your own organization. Use it to sync knowledge from your CMS, mirror contacts from your CRM, trigger outbound calls from your backend, and pull call transcripts, website chats, and usage numbers into your own reporting. Base URL: `https://api.smao.ai/api/v1`. API operations require a bearer token. The raw `/openapi.yaml` and `/openapi.json` specifications are public; retired `/docs` URLs redirect to this site before authentication. See [Authentication](/docs/authentication). There are no event-delivery webhooks: poll for state changes such as knowledge sync status or call results. New here? Start with the [quickstart](/docs/public/quickstart). ## What you can build [#what-you-can-build] Choose a selectable voice, create and configure the assistants that run your phone conversations, and manage the reusable prompts that drive them. Push knowledge-base entries that assistants retrieve from during calls - each item is embedded asynchronously for retrieval. Manage contact records and the groups that organize contacts, knowledge items, forwardings, glossary, and pronunciation rules. Read call history with transcripts and analyses, download recordings, and trigger outbound calls. Read the chats from your website widget - visitor details and the messages the visitor saw. Read-only; replies stay in the dashboard Inbox. Tune call behavior: forwarding targets, domain terms, word-level pronunciation overrides, and opening-hours calendars. Read-only aggregated call statistics - weekly and monthly usage, per-assistant rollups, and last-24h hourly counts. Custom webhook tools that assistants invoke during calls. Auth secrets are encrypted at rest and never returned by the API. ## Conventions [#conventions] API-defined field names are `snake_case`; caller-defined JSON such as tool parameters and custom headers keeps the keys you supply. Timestamps are ISO-8601 UTC, and object IDs are 24-character lowercase hex strings, except chat IDs, which are the UUIDs the dashboard Inbox shows. List endpoints use opaque cursor pagination (`limit` + `cursor`, `meta.next_cursor`). Errors use a single envelope - see [Errors](/docs/errors). Matched operations that reach the rate limiter use per-key buckets with defaults of 1000 reads/min, 200 writes/min, and 60 `POST /calls` requests/hour. Successful responses include `X-RateLimit-*` headers; see [Errors](/docs/errors) for exceptions and retry behavior. ## API reference [#api-reference] The full endpoint reference is generated from the OpenAPI spec - see the sidebar. Each endpoint page includes an interactive playground. The raw spec (tenant-branded per host) is at [**SMAO\_API\_URL**/api/v1/openapi.yaml](https://api.smao.ai/api/v1/openapi.yaml). # Mailbox detection (https://docs.smao.ai/docs/public/mailbox-detection) Use the existing [outbound call API](/docs/public/calls/createCall) with mailbox detection enabled, then poll [getCall](/docs/public/calls/getCall). The endpoint reference owns the request and outcome schemas. This guide describes how a consumer such as n8n should coordinate a lead list. It is an integration recipe, not an installed workflow or a campaign scheduler inside SMAO. Detection is off unless the request sets `mailbox_detection` to `true`. It currently supports German assistants. For an assistant in any other language the call runs unchanged and reports detection as skipped with the reason `language_not_supported`. ## Keep a durable attempt ledger [#keep-a-durable-attempt-ledger] Store each lead's identifier, normalized phone number, attempt count, current call identifier, processed call identifiers, next attempt time and processing status. Use a durable store with atomic updates: one lead can have only one active attempt. A repeated poll or resumed execution must not increment the counter or schedule a retry twice. Reserve an attempt before sending the request. Count three total attempts, not three retries. A confirmed pre-dial validation/rate rejection can release the reservation. Save the returned call identifier before continuing. Outbound creation has no idempotency guarantee. Disable automatic retries on the outbound HTTP Request node. If it times out, loses its response or returns an ambiguous server failure, pause the lead for manual reconciliation. The call may already exist. Retry only after confirming whether that attempt was accepted and recovering its call identifier or releasing its reservation. ## Poll and make one decision per ended call [#poll-and-make-one-decision-per-ended-call] A practical node sequence is: 1. Read the next due lead and atomically reserve its attempt. 2. HTTP Request: create the outbound call with mailbox detection enabled. 3. Save the returned call identifier in the ledger. 4. Wait, then HTTP Request: read that call. 5. Continue polling until the call is done and detection is terminal. 6. Switch on the detection outcome, then persist the decision atomically. Start with five-second polls for the first minute and fifteen-second polls after that. GET retries are safe. Honor Retry-After and rate-limit headers, including backoff when the limiter is temporarily unavailable. After thirty minutes without the required completion/result, mark the lead for review instead of redialing. Pace outbound requests at least sixty seconds apart per assistant and respect the outbound bucket (default sixty per hour per API key). GET has a separate read bucket (default one thousand per minute). These are existing limits. | Ended-call result | Action | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | Completed machine-vm, machine-ivr or machine-unavailable; attempts below three | Set the next attempt to at least the call end time plus two hours. | | Completed machine category; third attempt | Mark attempts exhausted. | | Completed human or uncertain | Wait for the configured qualification analysis and use its result. | | Failed, skipped, absent or inconsistent detection | Mark for review. | | Busy or no-answer without a machine verdict | No automatic retry in this policy. | Use UTC instants for scheduling. If the call has no end timestamp, mark it for review. A machine verdict while the call is still active is not permission to start another call. For example: a first machine call ends at 10:00 UTC; the next attempt is due no earlier than 12:00 UTC. If that call ends at 12:01 UTC, the third is due no earlier than 14:01 UTC. A third machine answer exhausts the lead. The two-hour wait and attempt limit belong to your consumer, not to SMAO. Persist scheduled times so restarts and duplicated executions cannot create extra calls. ## Qualification is a separate result [#qualification-is-a-separate-result] Configured analyses, CRM actions and notifications continue for all calls, including machine calls. Evaluate the detection branch before acting on their results in n8n. Call success is a generic call-quality field, not a lead's qualification status. An ended call can still have an analysis running. Select the qualification analysis configured for your assistant, wait for its completed result and evaluate its configured output. Failed analyses and missing results require review. Do not invent a qualification result from an empty transcript or failed detection. Before running a real campaign, verify your workflow's duplicate-execution handling, ledger recovery, request pacing and ambiguous-request procedure with controlled leads. SMAO's detection support alone does not certify those consumer behaviors. # Quickstart (https://docs.smao.ai/docs/public/quickstart) From a fresh API key to a live outbound call. All examples use `https://api.smao.ai/api/v1`. ## 1. Create an API key [#1-create-an-api-key] In the dashboard, go to **Settings → API Keys** and create a key. Each key is scoped to your organization and to a single **Role**, which determines the endpoints it may call. Newly generated keys are \~43-character base64url strings with no prefix; legacy `smao_`-prefixed keys remain valid. Export it for the examples below: ```bash export SMAO_API_KEY="" ``` ## 2. Check connectivity [#2-check-connectivity] ```bash curl https://api.smao.ai/api/v1/ping \ -H "Authorization: Bearer $SMAO_API_KEY" ``` ```json { "data": { "ok": true, "now": "2026-07-01T12:00:00.000Z" } } ``` A `401` with `error.code = "unauthorized"` means the key is missing or invalid - see [Errors](/docs/errors). ## 3. Choose a voice [#3-choose-a-voice] Fetch the voices currently selectable for new assistants and voice changes: ```bash curl https://api.smao.ai/api/v1/voices \ -H "Authorization: Bearer $SMAO_API_KEY" ``` Use an `id` from the response as `voice_id` below. See [listVoices](/docs/public/voices/listVoices) for the response schema. ## 4. Create an assistant [#4-create-an-assistant] `name`, `language`, `voice_id`, `company_name`, `company_industry`, and `introduction` are required. The platform assigns the phone number; to use one of your organization's purchased numbers instead, send its `id` from [listPhoneNumbers](/docs/public/assistants/listPhoneNumbers) as `phone_number_id`. ```bash curl -X POST https://api.smao.ai/api/v1/assistants \ -H "Authorization: Bearer $SMAO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Sarah", "language": "en", "voice_id": "", "company_name": "Example GmbH", "company_industry": "Retail", "introduction": "Hello, you have reached Example GmbH. How can I help you?" }' ``` The `201` response contains the assistant under `data`, including its generated `id` (needed below) and assigned `phone_number`. See [createAssistant](/docs/public/assistants/createAssistant) for all optional fields. ## 5. Add knowledge [#5-add-knowledge] Knowledge items live in groups: create a `knowledges` group, add an item, attach the group to your assistant. ```bash curl -X POST https://api.smao.ai/api/v1/groups \ -H "Authorization: Bearer $SMAO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "knowledges", "name": "FAQ" }' ``` Take `data.id` from the response and create the item: ```bash curl -X POST https://api.smao.ai/api/v1/knowledge-items \ -H "Authorization: Bearer $SMAO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "group_id": "", "name": "Opening hours", "text": "We are open Monday to Friday, 9:00 to 18:00." }' ``` Attach the group to the assistant: ```bash curl -X PATCH https://api.smao.ai/api/v1/assistants/ \ -H "Authorization: Bearer $SMAO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "knowledge_group_ids": [""] }' ``` Embedding is asynchronous: `sync_status.state` moves `pending` → `syncing` → `synced` (or `failed`). There are no event webhooks - poll [getKnowledgeItem](/docs/public/knowledge-items/getKnowledgeItem) until the item is `synced` or `failed`, and apply a client-side timeout so a persistent `pending` state does not create an endless poll loop. ## 6. Place an outbound call [#6-place-an-outbound-call] Provide exactly one of `assistant_id` or `assistant_phone`, and exactly one of `to` or `target_phone`. Numbers are E.164 (`+` followed by 6–20 digits). ```bash curl -X POST https://api.smao.ai/api/v1/calls \ -H "Authorization: Bearer $SMAO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "assistant_id": "", "to": "+491701234999", "details": "Follow-up on order 4711." }' ``` ```json { "data": { "call_id": "65f8fa6f1a9d8e0012ab34cd", "assistant_id": "", "assistant_phone": "+491701234567", "to": "+491701234999", "status": "queued", "created_at": "2026-07-01T12:00:00.000Z" } } ``` `POST /calls` defaults to a **60 requests/hour** bucket per API key and also permits only one outbound call per assistant in any 60-second window. On `429`, use `Retry-After` when present; otherwise wait at least 60 seconds and retry with exponential backoff. Once the call completes, poll [getCall](/docs/public/calls/getCall) for the transcript and analyses, and [getCallRecording](/docs/public/calls/getCallRecording) for the audio. For bounded machine-answer retries, see the [mailbox detection guide](/docs/public/mailbox-detection). ## 7. Where next [#7-where-next] * The full endpoint reference in the sidebar - every page has an interactive playground. * [Errors](/docs/errors) - error envelope, codes, and retry guidance. * [Authentication](/docs/authentication) - key handling and role scoping. # Public API OpenAPI specification (https://api.smao.ai/api/v1/openapi.yaml) ````yaml openapi: 3.1.0 info: title: SMAO Public API version: "1.8.1" summary: Programmatic access to assistants, selectable voices, contact data, calls and recordings, website chats, and usage statistics. description: | The SMAO Public API gives you programmatic access to the resources you manage in the dashboard: assistants, selectable voices, assistant configuration (prompts, knowledge items, glossary, pronunciation, forwardings, calendars, tools), contact data, call history with transcripts and recordings, outbound call triggering, website chats from the Inbox (read-only), and usage statistics. ## Base URL Base URL: `https://api.smao.ai/api/v1` ## Authentication All endpoints (except `/openapi.*` and `/docs`) require a bearer token in the `Authorization` header: ``` Authorization: Bearer ``` API keys are created in the SMAO dashboard under *Settings → API Keys*. Each key is scoped to a single organization and a single role. If that organization is blocked, every request with its keys returns HTTP 403 `organization_blocked` before any other check. ## Response shape **Success (2xx):** ```json { "data": { … } } ``` or for paginated list endpoints: ```json { "data": [ … ], "meta": { "has_more": false, "next_cursor": null } } ``` **Error (4xx / 5xx):** ```json { "error": { "code": "validation_failed", "message": "…", "details": [] } } ``` Hosted SMAO operational writes can be denied after trial exhaustion while dashboard history remains accessible. Knowledge-item and prompt creation or updates return HTTP 403 with `trial_exhausted`, `subscription_required`, or `organization_access_required` when operational admission fails. Existing public API read entitlements and tenant/role checks remain unchanged. ## Field naming API-defined request and response field names use `snake_case`. Caller-defined JSON objects such as tool parameters and custom webhook headers retain the keys supplied by the caller. Timestamps are ISO-8601 strings in UTC. Object IDs are 24-character lowercase hex strings, except chat IDs, which are the UUIDs the dashboard Inbox shows. ## Pagination List endpoints use **opaque cursor pagination**. Send `?limit=N` on the first request; if `meta.has_more` is `true`, pass `meta.next_cursor` back as the `cursor` query parameter for the next page. Cursors are opaque - do not parse or construct them. ## Rate limiting Matched operations are rate-limited per API key. After authentication, the feature gate, and the limiter succeed, responses include: - `X-RateLimit-Limit` - bucket limit - `X-RateLimit-Remaining` - remaining requests in the current window - `X-RateLimit-Reset` - unix timestamp when the window resets Default buckets (deployments may override these values): | Category | Limit | Applies to | |---|---|---| | `read` | 1000 / min | All GET endpoints | | `write` | 200 / min | POST / PATCH / DELETE on all resources except `POST /calls` | | `outbound_trigger` | 60 / hour | POST /calls | `POST /calls` also permits only one outbound call per assistant in any 60-second window. When a bucket is exhausted you receive HTTP `429` with `error.code = rate_limit_exceeded` and `Retry-After`. The per-assistant cooldown and limiter-storage failure use the same status/code but do not include `Retry-After`. Authentication failures, feature-gate failures, unmatched routes, and limiter-storage failures do not include the complete rate-limit header set. contact: name: SMAO Support email: support@smao.ai license: name: Proprietary identifier: LicenseRef-Proprietary servers: - url: https://api.smao.ai/api/v1 description: API server tags: - name: Meta description: Health and connectivity. - name: Contacts description: Create and manage contact records - phone numbers, names, emails, group assignment. - name: Groups description: Organize contacts, knowledge items, forwardings, glossary entries, and pronunciation rules. - name: Knowledge-items description: Knowledge-base entries used by assistants for RAG. - name: Prompts description: | Reusable assistant prompts (answering and email templates). When automated prompt rating is enabled for the deployment, every save (`POST` or `PATCH`) runs deterministic checks first. Blocking defects are rated immediately; other prompts enter asynchronous quality review. See the `Prompt` schema for the rating fields and their lifecycle. - name: Assistants description: Voice assistants - the phone-answering bots that run your conversations. - name: Voices description: Voices currently selectable for new assistants and voice changes. - name: Forwardings description: Call-forwarding targets (phone or SIP) that assistants route to. - name: Glossary description: Domain-specific terms and abbreviations assistants use during calls. - name: Pronunciation description: Word-level pronunciation overrides that assistants apply when speaking during calls. Each rule matches one written token, not a multi-word phrase. Replacement text and spell-out affect both voice generations; speaking-rate rules and IPA transcriptions are applied on v2 voices and ignored on v1 voices. - name: Calendars description: Opening-hours calendars - multi-schedule (opening/service/office/operating), weekly templates, and date-specific overrides that assistants consult during calls. - name: Calls description: Read call history, fetch recordings, and trigger outbound calls. - name: Chats description: >- Read-only access to website-widget chats from the dashboard Inbox - the chat, the visitor's details, and the messages the visitor saw. Requires the widgets feature and a role that can view Inbox conversations. Replying, takeover, assignment, and status changes stay in the dashboard. - name: Statistics description: Aggregated call statistics - weekly and monthly usage breakdowns, per-assistant rollups, and last-24h hourly counts. Read-only, computed at query time. - name: Tools description: >- Custom webhook tools that assistants invoke during calls. v1 supports webhook-type tools only (integration tools are created via the dashboard OAuth flow). Auth secrets are encrypted at rest with AES-256-GCM and are never returned by the API - the response surfaces `has_secret: boolean` instead. security: - bearerAuth: [] paths: # ── Meta ────────────────────────────────────────────────────────────────── /ping: get: operationId: getPing tags: [Meta] summary: Health check description: Confirms the API is reachable and your API key is valid. responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" } content: application/json: schema: type: object properties: data: type: object properties: ok: { type: boolean, example: true } now: { type: string, format: date-time } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Contacts ────────────────────────────────────────────────────────────── /contacts: get: operationId: listContacts tags: [Contacts] summary: List contacts (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit200" - name: group_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by contacts group. responses: "200": description: Page of contacts. content: application/json: schema: { $ref: "#/components/schemas/PageContact" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createContact tags: [Contacts] summary: Create a contact requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ContactCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Contact" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /contacts/{contact_id}: parameters: - $ref: "#/components/parameters/ContactId" get: operationId: getContact tags: [Contacts] summary: Get a contact responses: "200": description: The contact. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Contact" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateContact tags: [Contacts] summary: Update a contact description: Partial update - only included fields are changed. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ContactUpdate" } responses: "200": description: Updated contact. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Contact" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteContact tags: [Contacts] summary: Delete a contact responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Groups ──────────────────────────────────────────────────────────────── /groups: get: operationId: listGroups tags: [Groups] summary: List groups (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit200" - name: type in: query schema: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] } description: Filter by group type. responses: "200": description: Page of groups. content: application/json: schema: { $ref: "#/components/schemas/PageGroup" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createGroup tags: [Groups] summary: Create a group requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GroupCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Group" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /groups/{group_id}: parameters: - $ref: "#/components/parameters/GroupId" get: operationId: getGroup tags: [Groups] summary: Get a group responses: "200": description: The group. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Group" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateGroup tags: [Groups] summary: Update a group requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GroupUpdate" } responses: "200": description: Updated group. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Group" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteGroup tags: [Groups] summary: Delete a group description: | Permanently deletes the group and all contacts, knowledge items, forwardings, glossary entries, or pronunciation rules it contains. Knowledge chunks are also removed. This operation is not reversible. Fails with 409 `conflict` if the group is assigned to an assistant or is the organization's last group of that type. responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Knowledge-items ─────────────────────────────────────────────────────── /knowledge-items: get: operationId: listKnowledgeItems tags: [Knowledge-items] summary: List knowledge items (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" - name: group_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by knowledges group. responses: "200": description: Page of knowledge items. content: application/json: schema: { $ref: "#/components/schemas/PageKnowledgeItem" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createKnowledgeItem tags: [Knowledge-items] summary: Create a knowledge item description: | Creates a new knowledge item and asynchronously embeds its text for retrieval. `sync_status.state` follows the lifecycle: `pending` → `syncing` → `synced` (success) or `failed` (terminal). Poll the item via `GET` to observe the transition. `pending` is non-terminal: use a client-side polling timeout because a queueing failure can leave the item pending. A later valid `PATCH` re-enqueues indexing; contact support if the state does not progress. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/KnowledgeItemCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/KnowledgeItem" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /knowledge-items/{knowledge_item_id}: parameters: - $ref: "#/components/parameters/KnowledgeItemId" get: operationId: getKnowledgeItem tags: [Knowledge-items] summary: Get a knowledge item (with full text) responses: "200": description: The knowledge item. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/KnowledgeItemDetail" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateKnowledgeItem tags: [Knowledge-items] summary: Update a knowledge item description: | Every update currently re-indexes the item, including changes limited to `name`, `group_id`, or `active`. `sync_status.state` resets to `pending` and follows the same lifecycle as a fresh create: `pending` → `syncing` → `synced` (success) or `failed` (terminal). Use a client-side polling timeout because a queueing failure can leave the item pending. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/KnowledgeItemUpdate" } responses: "200": description: Updated. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/KnowledgeItem" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteKnowledgeItem tags: [Knowledge-items] summary: Delete a knowledge item responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Prompts ─────────────────────────────────────────────────────────────── /prompts: get: operationId: listPrompts tags: [Prompts] summary: List prompts (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" responses: "200": description: Page of prompts. content: application/json: schema: { $ref: "#/components/schemas/PagePrompt" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createPrompt tags: [Prompts] summary: Create a prompt requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PromptCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Prompt" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /prompts/{prompt_id}: parameters: - $ref: "#/components/parameters/PromptId" get: operationId: getPrompt tags: [Prompts] summary: Get a prompt responses: "200": description: The prompt. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Prompt" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updatePrompt tags: [Prompts] summary: Update a prompt requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PromptUpdate" } responses: "200": description: Updated. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Prompt" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deletePrompt tags: [Prompts] summary: Delete a prompt description: | Before deleting, clear both `prompt_id` and `email_prompt_id` references from assistants. The server returns 409 `conflict` when the prompt is still assigned through `prompt_id`; clients must not rely on that guard to detect an `email_prompt_id` reference. responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Voices ─────────────────────────────────────────────────────────────── /voices: get: operationId: listVoices tags: [Voices] summary: List selectable voices description: | Returns the voices the authenticated instance can assign to a new assistant or select during a voice change. Legacy voices retained by existing assistants and voices reserved for internally managed lines are not returned. responses: "200": description: Selectable voices. content: application/json: schema: type: object required: [data] additionalProperties: false properties: data: type: array items: { $ref: "#/components/schemas/Voice" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /phone-numbers: get: operationId: listPhoneNumbers tags: [Assistants] summary: List purchased phone numbers for new assistants description: | Returns the purchased phone numbers your organization can give a new assistant: numbers your instance allocated to the organization that no assistant uses yet. Send a number's `id` as `phone_number_id` when creating an assistant. The list is empty when the organization has none; creating an assistant without `phone_number_id` then allocates a number automatically. Ordered by number. parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" responses: "200": description: Page of available purchased numbers. content: application/json: schema: { $ref: "#/components/schemas/PagePhoneNumber" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Assistants ─────────────────────────────────────────────────────────── /assistants: get: operationId: listAssistants tags: [Assistants] summary: List assistants (paginated) description: Returns a paginated list of assistants in your organization. parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" responses: "200": description: Page of assistants. content: application/json: schema: { $ref: "#/components/schemas/PageAssistant" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createAssistant tags: [Assistants] summary: Create an assistant description: | Creates a new assistant and provisions a phone number for it. The `phone_number` field on the response is assigned by the platform - callers cannot set or change it via this API. To use one of your organization's purchased numbers instead, send its `id` from `GET /phone-numbers` as `phone_number_id`: a number that is not available to your organization returns 404, one taken meanwhile 409. Cross-resource refs (prompt_id, knowledge_group_ids, etc.) are validated to belong to your organization before the assistant is saved. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/AssistantCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Assistant" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /assistants/{assistant_id}: parameters: - $ref: "#/components/parameters/AssistantId" get: operationId: getAssistant tags: [Assistants] summary: Get an assistant description: Returns the configuration of an assistant in your organization. responses: "200": description: The assistant. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Assistant" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateAssistant tags: [Assistants] summary: Update an assistant description: | Partial update - only included fields are changed. `phone_number` and `carrier` are read-only and cannot be updated via this endpoint. Cross-resource refs are validated to belong to your organization. When `introduction` is included, the successful response confirms that the new opening text has been committed to the active assistant configuration used by the dashboard and by newly initialized calls. Existing localized translations are regenerated asynchronously; already initialized or in-progress calls keep the configuration they loaded earlier. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/AssistantUpdate" } responses: "200": description: Updated assistant. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Assistant" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteAssistant tags: [Assistants] summary: Delete an assistant responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Forwardings ─────────────────────────────────────────────────────────── /forwardings: get: operationId: listForwardings tags: [Forwardings] summary: List forwardings (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" - name: group_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by forwardings group. responses: "200": description: Page of forwardings. content: application/json: schema: { $ref: "#/components/schemas/PageForwarding" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createForwarding tags: [Forwardings] summary: Create a forwarding description: | Creates a new forwarding target inside a forwardings-type group. The referenced `group_id` must belong to your organization and have `type: forwardings`. When `destination_type` is `sip`, `sip_uri` is required. When `destination_type` is `sip_refer`, `sip_refer_target` is required. For a usable `phone` destination, provide a non-empty `phone_number`; current request validation also accepts an omitted or empty value, which produces a target that cannot be dialed. requestBody: required: true content: application/json: example: group_id: "65a1234567890abcdef01234" type: queue destination_type: phone phone_number: "+12025550123" schema: { $ref: "#/components/schemas/ForwardingCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Forwarding" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: The referenced `group_id` does not exist in your organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: not_found, message: "Group not found" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /forwardings/{forwarding_id}: parameters: - $ref: "#/components/parameters/ForwardingId" get: operationId: getForwarding tags: [Forwardings] summary: Get a forwarding responses: "200": description: The forwarding. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Forwarding" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateForwarding tags: [Forwardings] summary: Update a forwarding description: | Partial update - only included fields are changed. `group_id` cannot be changed via this endpoint; create a new forwarding in the target group instead. When changing to `destination_type: phone`, also send a non-empty `phone_number`; the validator permits it to be omitted or empty, but the resulting target cannot be dialed. requestBody: required: true content: application/json: example: timeout: 30 schema: { $ref: "#/components/schemas/ForwardingUpdate" } responses: "200": description: Updated forwarding. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Forwarding" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteForwarding tags: [Forwardings] summary: Delete a forwarding responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Glossary ────────────────────────────────────────────────────────────── /glossary: get: operationId: listGlossary tags: [Glossary] summary: List glossary items (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" - name: group_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by glossaries group. responses: "200": description: Page of glossary items. content: application/json: schema: { $ref: "#/components/schemas/PageGlossary" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createGlossary tags: [Glossary] summary: Create a glossary item description: | Creates a new glossary item inside a glossaries-type group. The referenced `group_id` must belong to your organization and have `type: glossaries`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GlossaryCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Glossary" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: The referenced `group_id` does not exist in your organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: not_found, message: "Group not found" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /glossary/{glossary_id}: parameters: - $ref: "#/components/parameters/GlossaryId" get: operationId: getGlossary tags: [Glossary] summary: Get a glossary item responses: "200": description: The glossary item. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Glossary" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateGlossary tags: [Glossary] summary: Update a glossary item description: | Partial update - only included fields are changed. `group_id` cannot be changed via this endpoint; create a new glossary item in the target group instead. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/GlossaryUpdate" } responses: "200": description: Updated glossary item. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Glossary" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteGlossary tags: [Glossary] summary: Delete a glossary item responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Pronunciation ─────────────────────────────────────────────────────────── /pronunciation: get: operationId: listPronunciation tags: [Pronunciation] summary: List pronunciation items (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" - name: group_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by pronunciations group. responses: "200": description: Page of pronunciation items. content: application/json: schema: { $ref: "#/components/schemas/PagePronunciation" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createPronunciation tags: [Pronunciation] summary: Create a pronunciation item description: | Creates a new pronunciation item inside a pronunciations-type group. The referenced `group_id` must belong to your organization and have `type: pronunciations`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PronunciationCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Pronunciation" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: The referenced `group_id` does not exist in your organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: not_found, message: "Group not found" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /pronunciation/{pronunciation_id}: parameters: - $ref: "#/components/parameters/PronunciationId" get: operationId: getPronunciation tags: [Pronunciation] summary: Get a pronunciation item responses: "200": description: The pronunciation item. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Pronunciation" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updatePronunciation tags: [Pronunciation] summary: Update a pronunciation item description: | Partial update - only included fields are changed. `group_id` cannot be changed via this endpoint; create a new pronunciation item in the target group instead. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PronunciationUpdate" } responses: "200": description: Updated pronunciation item. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Pronunciation" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deletePronunciation tags: [Pronunciation] summary: Delete a pronunciation item responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Calendars ───────────────────────────────────────────────────────────── /calendars: get: operationId: listCalendars tags: [Calendars] summary: List calendars (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" responses: "200": description: Page of calendars. content: application/json: schema: { $ref: "#/components/schemas/PageCalendar" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createCalendar tags: [Calendars] summary: Create a calendar description: | Creates a new calendar. You can optionally seed one or more schedules (opening hours, service hours, etc.) along with their weekly template and date-specific overrides in a single request. Calendar names must be unique within your organization. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CalendarCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Calendar" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "409": description: A calendar with that name already exists. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: conflict, message: "A calendar with this name already exists" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /calendars/{calendar_id}: parameters: - $ref: "#/components/parameters/CalendarId" get: operationId: getCalendar tags: [Calendars] summary: Get a calendar responses: "200": description: The calendar. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Calendar" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateCalendar tags: [Calendars] summary: Update a calendar description: | Partial update - only included fields are changed. Passing `schedules` replaces the calendar's full schedules array. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CalendarUpdate" } responses: "200": description: Updated calendar. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Calendar" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: A calendar with that name already exists. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: conflict, message: "A calendar with this name already exists" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteCalendar tags: [Calendars] summary: Delete a calendar description: | Deletes a calendar. Fails with `409 conflict` if the calendar is still assigned to one or more assistants - unlink it from every assistant first (via the Assistants endpoint) and retry. responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Calendar is still assigned to one or more assistants. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: conflict, message: "This calendar is still being used by assistants" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Calls ───────────────────────────────────────────────────────────────── /calls: get: operationId: listCalls tags: [Calls] summary: List calls (paginated) parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitCalls" - name: assistant_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$" } description: Filter by assistant. - name: assistant_phone in: query schema: type: string pattern: "^\\+\\d{6,20}$" example: "+491701234567" description: Filter by assistant phone number. E.164 format - a leading `+` followed by 6–20 digits. - name: created_from in: query schema: { type: string, format: date-time } - name: created_to in: query schema: { type: string, format: date-time } responses: "200": description: Page of calls. content: application/json: schema: { $ref: "#/components/schemas/PageCall" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createCall tags: [Calls] summary: Trigger an outbound call description: | Starts an outbound call. Provide exactly one of `assistant_id` or `assistant_phone` to select the assistant, and exactly one of `to` or `target_phone` for the destination number (`to` is preferred, `target_phone` is an alias retained for backwards compatibility). This operation uses the per-key `outbound_trigger` bucket (60 requests per hour by default) and a separate one-outbound-call-per-assistant cooldown over a rolling 60-second window. A cooldown rejection is 429 `rate_limit_exceeded` without `Retry-After`. If the normalized destination equals the selected assistant's normalized own phone number, or exactly matches a caller-blocking entry assigned to that assistant, no call is created and the operation returns 409 `outbound_target_blocked`. The own-number restriction is intrinsic. Caller-blocking lists and Assistant assignments are managed in Dashboard → Settings → Blocked callers; the Public API does not expose caller-blocking configuration. Retrying the same destination while it remains blocked returns the same rejection. requestBody: required: true content: application/json: examples: assistantIdTo: summary: Assistant ID and preferred destination field value: assistant_id: "65a1234567890abcdef01234" to: "+12025550123" assistantIdTargetPhone: summary: Assistant ID and legacy destination field value: assistant_id: "65a1234567890abcdef01234" target_phone: "+12025550123" assistantPhoneTo: summary: Assistant phone and preferred destination field value: assistant_phone: "+12025550120" to: "+12025550123" assistantPhoneTargetPhone: summary: Assistant phone and legacy destination field value: assistant_phone: "+12025550120" target_phone: "+12025550123" schema: { $ref: "#/components/schemas/TriggerOutbound" } responses: "201": description: Call created and queued. content: application/json: schema: type: object properties: data: type: object required: [call_id, assistant_id, assistant_phone, to, status, created_at] properties: call_id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65f8fa6f1a9d8e0012ab34cd" } assistant_id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } assistant_phone: type: [string, "null"] pattern: "^\\+\\d{6,20}$" example: "+491701234567" description: E.164 phone number of the assistant, or null if the assistant has no phone number. to: type: string pattern: "^\\+\\d{6,20}$" example: "+491701234999" description: Normalized E.164 destination number. status: { type: string, example: "queued" } created_at: { type: string, format: date-time } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: The normalized destination is the selected assistant's own phone number or is blocked for that assistant. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: outbound_target_blocked, message: "The target phone number is blocked for this assistant" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /calls/{call_id}: parameters: - $ref: "#/components/parameters/CallId" get: operationId: getCall tags: [Calls] summary: Get a call with transcript, analyses, and notes responses: "200": description: The call. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/CallDetail" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /calls/{call_id}/recording: parameters: - $ref: "#/components/parameters/CallId" get: operationId: getCallRecording tags: [Calls] summary: Download the call recording description: | Returns the audio recording as a binary stream. Supports HTTP `Range` for partial content. Returns 404 with JSON envelope when no recording exists for the call. responses: "200": description: | Audio stream. The actual `Content-Type` depends on the recording's stored format - current recordings are FLAC, legacy recordings may be WAV. Clients should dispatch on the response `Content-Type` rather than assuming a single format. content: audio/flac: schema: { type: string, format: binary } audio/wav: schema: { type: string, format: binary } audio/mpeg: schema: { type: string, format: binary } "206": description: Partial content (byte range response). "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteCallRecording tags: [Calls] summary: Permanently delete the call recording description: | **Irreversible.** Erases every audio artifact of the call from object storage - the master recording, both channel recordings, the playback transcode and the waveform data - and reports success only after the deletion has been verified. The call itself is kept: transcript, analyses, notes, metadata and diagnostics are untouched, and the call keeps appearing in `GET /calls`. Afterwards `recording.available` on the call is `false` and `GET /calls/{call_id}/recording` returns 404. Requires an API key whose role carries the `call_logs.call_recording` delete permission; keys without it receive 403. Repeating the call on an already-erased recording succeeds without changing anything, and a call that never had a recording returns 404. A call that is still running, or whose recording is still being processed, is rejected with 409 - retry once the call has ended. A 500 means the audio was **not** fully removed and nothing was changed; the recording stays downloadable and the call is safe to retry. responses: "200": description: The recording was deleted and the deletion was verified. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Chats ───────────────────────────────────────────────────────────────── /chats: get: operationId: listChats tags: [Chats] summary: List website chats (paginated) description: | Chats from the website widget, newest activity first (`last_message_at` descending). Chats in which the visitor never wrote are not listed. The order follows activity, so a chat that receives a message while you page moves back to the front: it shows up on your next sync, not in the current pass. For incremental sync, keep the newest `last_message_at` you have seen and pass it as `last_message_from` next time. parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitCalls" - name: status in: query schema: { type: string, enum: [open, closed] } description: Only open or only closed chats. Omit for both. - name: widget_id in: query schema: { type: string, pattern: "^[a-f0-9]{24}$", example: "65f8fa6f1a9d8e0012ab34ce" } description: Filter by widget. - name: needs_human in: query schema: { type: boolean } description: "`true` - the chat waits for a reply from the team: the visitor was handed over or wrote in a chat the team answers, and no team member has replied or taken over since." - name: last_message_from in: query schema: { type: string, format: date-time } description: Only chats whose last message is at or after this time. - name: last_message_to in: query schema: { type: string, format: date-time } description: Only chats whose last message is at or before this time. responses: "200": description: Page of chats. content: application/json: schema: { $ref: "#/components/schemas/PageChat" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /chats/{chat_id}: parameters: - $ref: "#/components/parameters/ChatId" get: operationId: getChat tags: [Chats] summary: Get a website chat with its messages description: | The chat and the messages the visitor saw, oldest first: visitor messages, AI replies, team replies, and system notices. Internal messages such as the AI's tool calls are not included. Reading a chat never marks it as read. responses: "200": description: The chat. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ChatDetail" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Statistics ──────────────────────────────────────────────────────────── /statistics/summary: get: operationId: getStatisticsSummary tags: [Statistics] summary: List available statistics filters description: | Returns the filter values that populate statistics dashboards: every ISO (week, year) pair, in German time (Europe/Berlin), in which at least one completed call of the organization started, plus every assistant in the organization. The `all` assistant sentinel is **not** returned - clients can always pass `assistant_id=all` to the `/usage` endpoint regardless of this list. responses: "200": description: Filter values. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/StatisticsSummary" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /statistics/usage: get: operationId: getStatisticsUsage tags: [Statistics] summary: Usage breakdown for a week + assistant filter description: | Returns the monthly + weekly + last-24h breakdown for the selected week/year window. `assistant_id=all` aggregates across every assistant; passing a concrete 24-hex ID filters to that assistant's calls only. Supplying an ID that does not belong to the caller's organization returns 404. All duration fields are whole **seconds** (rounded down), not minutes, rated the way SMAO invoices calls: each call counts its recorded assistant session time with a minimum of 60 seconds, and the time stops once the platform hands the call off completely (a completed SIP REFER transfer). A call without recorded session time counts 0 seconds, for example a call bypassed to another destination before it reaches the assistant or one rejected by admission policy. Calls fall into weeks, days and hours by their start time in German time (Europe/Berlin); deleted calls count the usage recorded when they were deleted. `hourly_call_counts_last_24h` always reflects the 24h window ending at request time - it ignores the `week`/`year` filter. parameters: - name: week in: query required: true schema: { type: integer, minimum: 1, maximum: 53, example: 17 } description: ISO week number (1–53). The week runs Monday 00:00 to Sunday 24:00 in German time (Europe/Berlin). - name: year in: query required: true schema: { type: integer, minimum: 2020, maximum: 2100, example: 2026 } - name: assistant_id in: query required: true description: Either the sentinel `all` or a 24-hex assistant id. schema: oneOf: - type: string enum: [all] - type: string pattern: "^[a-f0-9]{24}$" examples: all: { value: "all", summary: "Aggregate across every assistant" } one: { value: "65a1234567890abcdef01234", summary: "Filter to one assistant" } responses: "200": description: Usage breakdown. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/StatisticsUsage" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": description: The supplied `assistant_id` does not exist in this organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: not_found, message: "Assistant not found" } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /statistics/assistants: get: operationId: getStatisticsAssistants tags: [Statistics] summary: Per-assistant usage for a week description: | Returns one breakdown row per assistant that had at least one completed call starting in the selected week. Includes assistants that have since been deleted - their calls still count and the row carries `is_deleted: true`. Duration fields are integer seconds, rated as described for `/statistics/usage`. parameters: - name: week in: query required: true schema: { type: integer, minimum: 1, maximum: 53, example: 17 } description: ISO week number (1–53). The week runs Monday 00:00 to Sunday 24:00 in German time (Europe/Berlin). - name: year in: query required: true schema: { type: integer, minimum: 2020, maximum: 2100, example: 2026 } responses: "200": description: Per-assistant usage. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/StatisticsAssistantsUsage" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } # ── Tools ──────────────────────────────────────────────────────────────── /tools: get: operationId: listTools tags: [Tools] summary: List tools (paginated) description: | Returns the organization's custom tools, ordered by `created_at` DESC. Auth configuration is summarized - the stored secret value is never returned; `auth.has_secret` indicates whether a credential is set. parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/LimitDefault" responses: "200": description: Page of tools. content: application/json: schema: { $ref: "#/components/schemas/PageTool" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } post: operationId: createTool tags: [Tools] summary: Create a tool description: | Creates a new webhook-type tool. `tool_key` must be unique within your organization; reuse returns 409. Auth secrets are stored AES-256-GCM encrypted and are never returned - submit them on create / PATCH to set or rotate. Integration-type tools must be created via the dashboard (OAuth flow) and cannot be created through the public API in v1. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ToolCreate" } responses: "201": description: Created. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Tool" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "409": description: A tool with the requested `tool_key` already exists in this organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: conflict, message: "A tool with that tool_key already exists." } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } /tools/{tool_id}: parameters: - $ref: "#/components/parameters/ToolId" get: operationId: getTool tags: [Tools] summary: Get a tool responses: "200": description: The tool. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Tool" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } patch: operationId: updateTool tags: [Tools] summary: Update a tool description: | Partial update - only included fields are changed. `tool_key` and `execution_type` are immutable (create a new tool instead). To rotate an auth secret, submit a full `auth` object with the new value; the previous secret is overwritten in place. A stored credential never follows a URL change: a PATCH that changes `webhook.url` of a tool with a stored credential must include `auth` with that credential, otherwise it returns 400 and nothing changes. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ToolUpdate" } responses: "200": description: Updated tool. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Tool" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } delete: operationId: deleteTool tags: [Tools] summary: Delete a tool description: | Deletes the tool and removes its `_id` from any assistant's `tool_ids` list so no assistant is left referencing a missing tool. responses: "200": description: Deleted. content: application/json: schema: { $ref: "#/components/schemas/EmptySuccess" } "400": { $ref: "#/components/responses/ValidationFailed" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/SubscriptionRequired" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/InternalError" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: | API key in the `Authorization: Bearer ` header. Keys are created in the SMAO dashboard (*Settings → API Keys*). headers: XRateLimitLimit: description: Maximum requests allowed in the current window for this bucket. schema: { type: integer, example: 1000 } XRateLimitRemaining: description: Requests remaining in the current window. schema: { type: integer, example: 997 } XRateLimitReset: description: Unix timestamp (seconds) when the window resets. schema: { type: integer, example: 1776902940 } RetryAfter: description: Seconds to wait before retrying after a 429 response. schema: { type: integer, example: 60 } parameters: Cursor: name: cursor in: query description: | Opaque cursor from a prior `meta.next_cursor`. Base64url-encoded (unpadded) - do not parse or construct it manually. schema: type: string pattern: "^[A-Za-z0-9_-]+$" # Page-size buckets: contacts and groups allow up to 200 per page, calls # default lower (25), everything else is the standard 1–100/50. Limit200: name: limit in: query description: Items per page (1–200, default 50). schema: { type: integer, minimum: 1, maximum: 200, default: 50 } LimitDefault: name: limit in: query description: Items per page (1–100, default 50). schema: { type: integer, minimum: 1, maximum: 100, default: 50 } LimitCalls: name: limit in: query description: Items per page (1–100, default 25). schema: { type: integer, minimum: 1, maximum: 100, default: 25 } ContactId: name: contact_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } GroupId: name: group_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } KnowledgeItemId: name: knowledge_item_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } PromptId: name: prompt_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } AssistantId: name: assistant_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } ForwardingId: name: forwarding_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } GlossaryId: name: glossary_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } PronunciationId: name: pronunciation_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } CalendarId: name: calendar_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } CallId: name: call_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } ChatId: name: chat_id in: path required: true description: The chat's UUID, as the dashboard Inbox shows it and `listChats` returns it. example: "3f2b8c1e-9a4d-4c6b-8f1e-2d7a5b9c0e41" schema: type: string pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ToolId: name: tool_id in: path required: true example: "65f8fa6f1a9d8e0012ab34cd" schema: { type: string, pattern: "^[a-f0-9]{24}$" } responses: Unauthorized: description: Missing or invalid API key. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: unauthorized, message: "Missing API key" } } Forbidden: description: The API key's role lacks permission, an operational write is unavailable (trial_exhausted, subscription_required, organization_access_required), or the key's organization is blocked (organization_blocked). content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: forbidden, message: "No permission" } } NotFound: description: Resource not found. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: not_found, message: "Not found" } } ValidationFailed: description: Request body or query parameters failed validation. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: error: code: validation_failed message: "Validation failed" Conflict: description: Resource is in use or the operation would violate an invariant. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: conflict, message: "Group is assigned to an assistant" } } RateLimited: description: >- Rate limit exceeded. Bucket exhaustion includes the rate-limit headers and `Retry-After`. The separate outbound per-assistant cooldown, or a fail-closed limiter-storage error, uses the same 429/code without `Retry-After`; a storage error may omit all rate-limit headers. headers: X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" } Retry-After: { $ref: "#/components/headers/RetryAfter" } content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: rate_limit_exceeded, message: "Rate limit exceeded" } } SubscriptionRequired: description: A feature required by this endpoint is not enabled for the organization. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: subscription_required, message: "This feature is not available in your current subscription. Please upgrade to a higher plan" } } InternalError: description: | Unexpected server error. The operation did not complete. Safe to retry idempotent reads; for mutations, check the resource state before retrying. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: { code: internal_error, message: "An unexpected error occurred." } } schemas: # ── Shared ──────────────────────────────────────────────────────────── Error: type: object required: [error] properties: error: type: object required: [code, message] properties: code: type: string enum: - unauthorized - forbidden - not_found - validation_failed - conflict - outbound_target_blocked - rate_limit_exceeded - subscription_required - organization_blocked - internal_error message: { type: string } details: type: array description: Optional validation details. Present only for schema/model validation failures. items: type: object properties: path: type: array items: { type: [string, integer] } message: { type: string } EmptySuccess: type: object properties: data: type: object description: Empty - present for envelope consistency. PageMeta: type: object required: [has_more, next_cursor] properties: has_more: { type: boolean } next_cursor: type: [string, "null"] description: Opaque cursor for the next page, or `null` on the last page. # ── Contacts ────────────────────────────────────────────────────────── Contact: type: object required: - id - group_id - group_name - phone_numbers - first_name - last_name - email - company - gender - prompt - preferred_language - forwarding_id - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } group_id: type: [string, "null"] example: "65a1234567890abcdef01234" description: >- `null` for legacy or orphaned records with no group. Deleting a contacts group through this API deletes its contacts rather than clearing their group reference. group_name: { type: [string, "null"], example: "VIP Customers" } phone_numbers: type: array items: { type: string, example: "+491701234567" } first_name: { type: string, example: "Ada" } last_name: { type: string, example: "Lovelace" } email: { type: string, example: "ada@example.com" } company: { type: string, example: "Analytical Engine Inc" } gender: type: string example: "female" description: Empty string means the contact's gender is not specified. prompt: { type: string, description: "Optional per-contact prompt fragment." } preferred_language: { type: string, example: "en" } forwarding_id: type: [string, "null"] example: "65a1234567890abcdef09999" created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ContactCreate: type: object required: [group_id, phone_numbers] additionalProperties: false example: group_id: "65a1234567890abcdef01234" phone_numbers: ["+12025550123"] first_name: "Ada" last_name: "Lovelace" properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } phone_numbers: type: array minItems: 1 items: { type: string, minLength: 1, example: "+491701234567" } first_name: { type: string, default: "" } last_name: { type: string, default: "" } email: type: string format: email default: "" description: Email address. Empty string is accepted if you don't have the contact's email. company: { type: string, default: "" } prompt: { type: string, default: "" } preferred_language: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi, unknown] example: "en" gender: type: string default: "" description: Optional gender value. Omit it or send an empty string when not specified. forwarding_id: type: [string, "null"] pattern: "^[a-f0-9]{24}$" default: null ContactUpdate: type: object minProperties: 1 additionalProperties: false description: Partial update - include only the fields you want to change. example: first_name: "Grace" properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } phone_numbers: type: array minItems: 1 items: { type: string, minLength: 1 } first_name: { type: string } last_name: { type: string } email: type: string format: email description: Email address. Empty string is accepted if you don't have the contact's email. company: { type: string } prompt: { type: string } preferred_language: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi, unknown] gender: type: string description: Send an empty string to clear the value; omit the property to preserve it. forwarding_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" } PageContact: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Contact" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Groups ──────────────────────────────────────────────────────────── Group: type: object required: [id, type, name, description, created_at, updated_at] properties: id: { type: string } type: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] } name: { type: string, example: "VIP Customers" } description: { type: string } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } GroupCreate: type: object required: [type, name] additionalProperties: false example: type: contacts name: "Priority Customers" properties: type: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] } name: type: string minLength: 1 maxLength: 200 description: Trimmed of leading/trailing whitespace before storage. description: { type: string, maxLength: 500, default: "" } GroupUpdate: type: object minProperties: 1 additionalProperties: false example: name: "Enterprise Customers" properties: name: type: string minLength: 1 maxLength: 200 description: Trimmed of leading/trailing whitespace before storage. description: { type: string, maxLength: 500 } PageGroup: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Group" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Knowledge-items ─────────────────────────────────────────────────── KnowledgeItemSyncStatus: type: object required: [state, synced_at, total_chunks, estimated_duration_ms, error] properties: state: type: string enum: [pending, syncing, synced, failed] description: Indexing status. synced_at: type: [string, "null"] format: date-time total_chunks: { type: [integer, "null"] } estimated_duration_ms: { type: [integer, "null"] } error: { type: [string, "null"] } KnowledgeItem: type: object description: Summary shape - `text` is omitted; use the detail endpoint for full body. required: [id, group_id, name, active, text_length, sync_status, created_at, updated_at] properties: id: { type: string } group_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } name: { type: string, example: "Shipping Policy" } active: { type: boolean } text_length: type: integer maximum: 5000000 description: Character count of the stored `text` body. sync_status: { $ref: "#/components/schemas/KnowledgeItemSyncStatus" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } KnowledgeItemDetail: allOf: - $ref: "#/components/schemas/KnowledgeItem" - type: object required: [text] properties: text: { type: string } KnowledgeItemCreate: type: object required: [group_id, name, text] additionalProperties: false example: group_id: "65a1234567890abcdef01234" name: "Shipping Policy" text: "Orders ship within two business days." properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } name: type: string minLength: 1 maxLength: 200 description: Trimmed of leading/trailing whitespace before storage. text: { type: string, minLength: 1, maxLength: 5000000 } active: { type: boolean, default: true } KnowledgeItemUpdate: type: object minProperties: 1 additionalProperties: false example: name: "International Shipping Policy" properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } name: type: string minLength: 1 maxLength: 200 description: Trimmed of leading/trailing whitespace before storage. text: { type: string, minLength: 1, maxLength: 5000000 } active: { type: boolean } PageKnowledgeItem: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/KnowledgeItem" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Prompts ─────────────────────────────────────────────────────────── Prompt: type: object required: - id - name - description - prompt - type - health_score - approved - feedback - rated_at - rating_status - created_at - updated_at description: | A reusable assistant prompt. When automated prompt rating is enabled for the deployment, every save (POST/PATCH) runs deterministic checks before any LLM review. The rating fields (`health_score`, `approved`, `feedback`, `rated_at`, `rating_status`) are read-only - create/update requests reject them - and follow one of these `rating_status` lifecycles: - Blocking deterministic defect: `null` → `"rated"` immediately, with `health_score: 0`, `approved: false`, localized feedback, and no semantic rating request. Prompt-language detection may run first. - Otherwise: `null` → `"pending"` (queued/in-flight) → `"rated"` or `"failed"` after asynchronous LLM review. When changed prompt content enters `"pending"`, stale rating fields are cleared instead of being shown for the new content. On `"failed"`, all rating fields except `rating_status` are cleared - the next save enqueues a fresh attempt. When rating is disabled, `rating_status`, `health_score`, `approved`, and `rated_at` remain `null`, and `feedback` remains an empty string. properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } name: { type: string, example: "Sales - Initial Outreach" } description: { type: string, example: "Opening prompt for cold outbound calls." } prompt: type: string description: The full prompt body passed to the assistant. type: type: string enum: [answering, email] description: Prompt kind - `answering` for voice/chat, `email` for email replies. created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } health_score: type: [integer, "null"] minimum: 0 maximum: 10 example: 8 description: | Automated quality score from 0–10. `0` is reserved for a blocking deterministic rule; the LLM judge returns only 1–10. `null` until the first rating completes, and after a failed rating attempt. approved: type: [boolean, "null"] example: true description: | Whether the review considers the prompt production-ready. A deterministic score of `0` is always `false`. For LLM ratings, approval is independent of `health_score` - a prompt does not need a perfect score to be approved. feedback: type: string example: "The prompt follows the template exactly and is production-ready. No changes required." description: | Review feedback. All natural-language feedback is written in the dominant supported language detected from the current prompt. Request locale and assistant language do not select this language, and unsupported or unclassifiable prompts fail with empty feedback instead of falling back to another language. Empty string until the first rating completes, while changed content is pending, and after a failed rating attempt. rated_at: type: [string, "null"] format: date-time description: When the latest rating completed. `null` until the first rating completes, and after a failed attempt. rating_status: type: [string, "null"] enum: [null, "pending", "rated", "failed"] example: "rated" description: | Rating lifecycle status: - `null` - never rated, or automated rating is disabled. - `"pending"` - save accepted, review queued or in flight. Rating fields are cleared when prompt content changed or the previous result was not language-verified. - `"rated"` - deterministic or LLM review complete; `health_score` / `approved` / `feedback` populated. - `"failed"` - review attempt failed. All other rating fields are cleared; the next save enqueues a fresh attempt. PromptCreate: type: object required: [name, prompt] additionalProperties: false example: name: "Reception" prompt: "Answer customer questions clearly and concisely." properties: name: { type: string, minLength: 1, maxLength: 200 } prompt: { type: string, minLength: 1, maxLength: 100000 } description: { type: string, maxLength: 2000, default: "" } type: type: string enum: [answering, email] default: answering PromptUpdate: type: object minProperties: 1 additionalProperties: false description: Partial update - include only the fields you want to change. example: description: "Reception prompt for inbound customer calls." properties: name: { type: string, minLength: 1, maxLength: 200 } prompt: { type: string, minLength: 1, maxLength: 100000 } description: { type: string, maxLength: 2000 } type: { type: string, enum: [answering, email] } PagePrompt: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Prompt" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Assistants ──────────────────────────────────────────────────────── Voice: type: object required: [id, name, languages, gender, version] additionalProperties: false properties: id: type: string minLength: 1 description: Voice ID to send as `voice_id` when creating or updating an assistant. example: "1887" name: type: string minLength: 1 example: Celine Weber languages: type: array minItems: 1 uniqueItems: true items: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi] example: [de] gender: type: string enum: [female, male, neutral] example: female version: type: string enum: [v1, v2] description: Voice version shown in the dashboard (`v1` or `v2`). example: v2 PreCallWebhook: type: [object, "null"] additionalProperties: false required: [enabled, url, method, headers, body_template, auth, timeout_ms] description: | Credential-free pre-call lookup configuration, or null if unconfigured. Authentication secrets and passwords are never returned. A returned configuration can be sent back on PATCH without resending its stored credentials. example: enabled: true url: "https://crm.example.com/lookup" method: POST headers: { X-Caller: "@callerNumber" } body_template: '{"caller":"@callerNumber","session":"@sessionId"}' timeout_ms: 5000 auth: { type: bearer } properties: enabled: { type: boolean, description: "False disables execution without removing the stored configuration or credentials." } url: type: [string, "null"] maxLength: 2000 description: HTTPS endpoint, required and nonempty when enabled. URLs are not templated. Runtime SSRF protection blocks private destinations and redirects. method: { type: string, enum: [GET, POST], description: "Defaults to POST on first configuration. GET sends no body; use templated headers for lookup parameters." } headers: type: [object, "null"] maxProperties: 50 propertyNames: { maxLength: 200, pattern: "^[A-Za-z0-9!#$%&'*+.^_`|~-]+$" } additionalProperties: { type: string, minLength: 1, maxLength: 2000 } description: Custom header values support the body_template variables. Names are literal. A supplied map replaces all custom headers; null clears them. Use auth for credentials, not this readable map. body_template: type: string maxLength: 50000 description: | JSON/text template for POST. Empty (the default) sends the standard lookup payload. GET ignores this field. Supported variables: `@callerNumber`, `@assistantNumber`, `@direction`, `@assistantId`, `@assistantName`, `@organizationId`, `@sessionId`, `@language`. The default payload uses those names without the @ prefix. Caller means the human in either call direction. timeout_ms: type: integer minimum: 500 maximum: 15000 description: Request timeout in milliseconds; defaults to 5000 on first configuration. The runner also bounds elapsed waiting to this value plus 500 ms. auth: type: [object, "null"] additionalProperties: false required: [type] description: Only fields relevant to the auth type are returned. Secret and password are never returned, including as ciphertext. properties: type: { type: string, enum: [none, apiKey, bearer, basic] } header_name: { type: string, minLength: 1, maxLength: 200, description: "apiKey only; omitted means x-api-key." } username: { type: string, minLength: 1, maxLength: 200, description: Basic auth only. } PreCallWebhookAuthInput: description: | Omit `auth` on first configuration for no authentication. On PATCH, omission preserves it; null or `{ "type": "none" }` removes authentication. Changing the auth type discards all fields belonging to the old type. `apiKey` and `bearer` require `secret` on first configuration or an auth-type change; `basic` requires `password`. On a same-type PATCH, omit the credential to retain it, or supply a new nonempty value to rotate it. A stored credential never follows a URL change: a PATCH that sets a different nonempty `url` must supply the credential again (or remove authentication), otherwise it returns 400 and nothing changes. Missing required credentials return 400, even when the webhook is disabled. Secrets and passwords are encrypted before saving and are never returned. oneOf: - title: No authentication type: "null" - title: None type: object additionalProperties: false required: [type] properties: type: { type: string, const: none } - title: API key type: object additionalProperties: false required: [type] description: Send secret in header_name, which defaults to x-api-key on first configuration. Omitting header_name on a same-type PATCH preserves the stored header name. properties: type: { type: string, const: apiKey } header_name: { type: string, minLength: 1, maxLength: 200, pattern: '\S' } secret: { type: string, minLength: 1, maxLength: 4000, writeOnly: true } - title: Bearer type: object additionalProperties: false required: [type] description: Send secret in the Authorization header using the Bearer scheme. properties: type: { type: string, const: bearer } secret: { type: string, minLength: 1, maxLength: 4000, writeOnly: true } - title: Basic type: object additionalProperties: false required: [type, username] description: Send username and password using HTTP Basic authentication. Include username whenever this auth object is supplied, even when retaining the stored password. properties: type: { type: string, const: basic } username: { type: string, minLength: 1, maxLength: 200 } password: { type: string, minLength: 1, maxLength: 4000, writeOnly: true } PreCallWebhookUrl: type: string format: uri maxLength: 2000 pattern: '^[Hh][Tt][Tt][Pp][Ss]://(?:[^/?#@]*@)?(?:\[[^\]]+\]|[^:/?#@\[\]]+)(?::[0-9]*)?(?:[/?#]|$)' PreCallWebhookInput: type: [object, "null"] additionalProperties: false required: [enabled] description: | Optional lookup that supplies caller context from your endpoint at the start of inbound and outbound calls. Add `@preCallData` to the assistant's assigned prompt to use the response. Saving this object does not call the endpoint or edit the prompt. Every supplied object requires `enabled`. When true, include a nonempty HTTPS `url` in the same request, even if a URL is already stored. String input limits count Unicode characters. On creation or first configuration, omitted fields default to POST, empty headers and body template, no authentication, and a 5000 ms timeout. Omit `pre_call_webhook` or send null on creation to leave it unconfigured. On PATCH, omit `pre_call_webhook` to preserve it, or send null to remove the configuration and credentials. A supplied object merges with the stored configuration; omitted nested fields retain their values, except that changing `url` requires the stored credential again (see `auth`). `{ "enabled": false }` disables execution while retaining the configuration and credentials. See `headers` and `auth` for their replacement rules. Enterprise assistants support the same webhook updates. A concurrent clear, auth-state change, or URL change of a webhook with a stored credential can return 409 without applying any part of the PATCH; fetch the latest assistant before retrying. The endpoint must return a successful (2xx) response containing JSON or text, up to 256 KiB. JSON is serialized to text for `@preCallData`; the injected text is limited to 4000 characters. Execution runs in the background without retries. Disabled, failed, timed-out or busy requests contribute an empty value. Prompt rendering waits at most 400 ms once per call; a later response can be used on later turns. example: enabled: true url: "https://crm.example.com/lookup" method: POST headers: { X-Session: "@sessionId" } body_template: '{"caller":"@callerNumber","direction":"@direction"}' timeout_ms: 5000 auth: { type: bearer, secret: "example-only-token" } properties: enabled: { $ref: "#/components/schemas/PreCallWebhook/properties/enabled" } url: description: | Required whenever enabled is true. Every nonempty value must be a valid HTTPS URL with a host, a numeric port if supplied, and valid percent escapes, even when disabled. URLs are not templated; private destinations and redirects are blocked. When disabled, omission preserves the stored URL on PATCH; null or an empty string clears it. anyOf: - type: [string, "null"] enum: ["", null] - $ref: "#/components/schemas/PreCallWebhookUrl" method: { $ref: "#/components/schemas/PreCallWebhook/properties/method" } headers: { $ref: "#/components/schemas/PreCallWebhook/properties/headers" } body_template: { $ref: "#/components/schemas/PreCallWebhook/properties/body_template" } timeout_ms: { $ref: "#/components/schemas/PreCallWebhook/properties/timeout_ms" } auth: { $ref: "#/components/schemas/PreCallWebhookAuthInput" } allOf: - if: type: object additionalProperties: true required: [enabled] properties: enabled: { const: true } then: additionalProperties: true required: [url] properties: url: { $ref: "#/components/schemas/PreCallWebhookUrl" } Assistant: type: object required: - id - name - description - language - voice_id - record_call - speed - tts_style_level - background_ambience - appointment_booking_enabled - contextual_asr_correction_enabled - allow_preferred_language - whitelist - introduction - company_name - company_industry - phone_number - carrier - prompt_id - email_prompt_id - knowledge_group_ids - glossary_group_ids - pronunciation_group_ids - forwarding_group_ids - contact_group_ids - calendar_ids - tool_ids - analysis_ids - pre_call_webhook - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } name: { type: string, example: "Sarah" } description: { type: string, example: "Receptionist assistant for inbound calls." } language: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi] example: "de" voice_id: type: string example: "cgSgspJ2msm6clMCkdW9" description: "Current voice ID. `GET /voices` returns IDs selectable for new assignments; an existing legacy ID can appear here but is not selectable." record_call: { type: boolean } speed: type: number minimum: 0.7 maximum: 1.2 description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400. tts_style_level: type: number minimum: 0 maximum: 1 multipleOf: 0.01 description: Voice style level from 0 (calm) to 1 (expressive). The platform calibrates this level for the selected voice. background_ambience: type: string enum: [clean, office, restaurant] description: Background ambience bed mixed under the assistant voice during phone calls. "clean" plays no bed. appointment_booking_enabled: { type: boolean } contextual_asr_correction_enabled: { type: boolean, description: "Per-turn LLM correction of misrecognised proper nouns (names, companies, products) in caller transcripts, using the assistant's own contacts, employees, glossary and knowledge. Adds latency on turns that carry a candidate. Default false." } allow_preferred_language: { type: boolean } whitelist: { type: boolean } introduction: type: string description: The supported scalar API view of the canonical localized opening configuration. This is the text returned by the dashboard and used for newly initialized calls in the original language. company_name: { type: string } company_industry: { type: string } phone_number: type: [string, "null"] description: Assigned by platform on create. Read-only - cannot be set or changed via this API. example: "+491701234567" carrier: type: [string, "null"] description: Telephony carrier assigned by the platform. Read-only. prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef05678" } knowledge_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } glossary_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } pronunciation_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } forwarding_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } contact_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } calendar_ids: type: array maxItems: 1 items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } description: At most one calendar per assistant. tool_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } description: | Webhook-type tools only. Integration-type / internal tools cannot be referenced via the public API and will return 404 on create/update. analysis_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } description: Call-analysis definition IDs to run on this assistant's calls. pre_call_webhook: { $ref: "#/components/schemas/PreCallWebhook" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } AssistantCreate: type: object required: [name, language, voice_id, company_name, company_industry, introduction] additionalProperties: false example: name: "Sarah" language: de voice_id: "cgSgspJ2msm6clMCkdW9" company_name: "Example GmbH" company_industry: "Logistics" introduction: "Hello, how can I help you today?" properties: phone_number_id: type: string pattern: "^[a-fA-F0-9]{24}$" description: A purchased number from `GET /phone-numbers`. Omit it to have a number allocated automatically. It is not stored on the assistant. example: "6710a3c2e4b0f1a2b3c4d5e6" name: { type: string, minLength: 1, maxLength: 100 } description: { type: string, maxLength: 500, default: "" } language: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi] description: "BCP-47-ish language code from the platform-supported list." voice_id: { type: string, description: "Voice ID. Must be one of the values returned by `GET /voices`; an unknown or unavailable ID returns 400." } company_name: { type: string, minLength: 1, maxLength: 200 } company_industry: { type: string, minLength: 1, maxLength: 200 } introduction: type: string minLength: 1 maxLength: 5000 description: Opening text in the assistant language. The platform stores it as the canonical original localized introduction and generates other supported-language variants asynchronously. It is the whole greeting; the assistant asks no follow-up question after it. record_call: { type: boolean } speed: type: number minimum: 0.7 maximum: 1.2 description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400. tts_style_level: type: number minimum: 0 maximum: 1 multipleOf: 0.01 description: Voice style level from 0 (calm) to 1 (expressive). Omit to use 0.5. background_ambience: type: string enum: [clean, office, restaurant] description: Background ambience bed mixed under the assistant voice during phone calls. Omit to use clean (no bed). appointment_booking_enabled: { type: boolean } contextual_asr_correction_enabled: { type: boolean, description: "Per-turn LLM correction of misrecognised proper nouns (names, companies, products) in caller transcripts, using the assistant's own contacts, employees, glossary and knowledge. Adds latency on turns that carry a candidate. Default false." } allow_preferred_language: { type: boolean } whitelist: { type: boolean } prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" } email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" } knowledge_group_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } glossary_group_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } pronunciation_group_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } forwarding_group_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } contact_group_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } calendar_ids: type: array default: [] maxItems: 1 items: { type: string, pattern: "^[a-f0-9]{24}$" } tool_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } analysis_ids: type: array default: [] items: { type: string, pattern: "^[a-f0-9]{24}$" } pre_call_webhook: { $ref: "#/components/schemas/PreCallWebhookInput" } AssistantUpdate: type: object minProperties: 1 additionalProperties: false description: Partial update - include only the fields you want to change. example: description: "Handles first-line customer support calls." properties: name: { type: string, minLength: 1, maxLength: 100 } description: { type: string, maxLength: 500 } language: type: string enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi] voice_id: { type: string, description: "Voice ID. Use a value returned by `GET /voices`. An existing legacy ID may remain unchanged, but changing to a legacy, unknown, or unavailable ID returns 400." } company_name: { type: string, minLength: 1, maxLength: 200 } company_industry: { type: string, minLength: 1, maxLength: 200 } introduction: type: string minLength: 1 maxLength: 5000 description: Replaces the canonical original opening text atomically. If `language` is omitted, the current original introduction language is retained; stale translations are discarded and regenerated asynchronously. record_call: { type: boolean } speed: type: number minimum: 0.7 maximum: 1.2 description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400. tts_style_level: type: number minimum: 0 maximum: 1 multipleOf: 0.01 description: Voice style level from 0 (calm) to 1 (expressive). background_ambience: type: string enum: [clean, office, restaurant] description: Background ambience bed mixed under the assistant voice during phone calls. appointment_booking_enabled: { type: boolean } contextual_asr_correction_enabled: { type: boolean, description: "Per-turn LLM correction of misrecognised proper nouns (names, companies, products) in caller transcripts, using the assistant's own contacts, employees, glossary and knowledge. Adds latency on turns that carry a candidate. Default false." } allow_preferred_language: { type: boolean } whitelist: { type: boolean } prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" } email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" } knowledge_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } glossary_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } pronunciation_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } forwarding_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } contact_group_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } calendar_ids: type: array maxItems: 1 items: { type: string, pattern: "^[a-f0-9]{24}$" } tool_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } description: | Webhook-type tools only. Integration-type / internal tools cannot be referenced via the public API and will return 404 on create/update. analysis_ids: type: array items: { type: string, pattern: "^[a-f0-9]{24}$" } pre_call_webhook: { $ref: "#/components/schemas/PreCallWebhookInput" } PageAssistant: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Assistant" } meta: { $ref: "#/components/schemas/PageMeta" } PhoneNumber: type: object required: [id, phone_number, account_label] additionalProperties: false properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" description: Send as `phone_number_id` when creating an assistant. example: "6710a3c2e4b0f1a2b3c4d5e6" phone_number: type: string description: The number in E.164. example: "+390612345601" account_label: type: string description: The provider account the number was purchased on, as your instance named it. example: "VoipVoice · main" PagePhoneNumber: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/PhoneNumber" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Forwardings ─────────────────────────────────────────────────────── DtmfSequenceItem: type: object required: [action] properties: action: type: string enum: [pause, dtmf] description: | Step kind: - `pause` - pause for `duration` ms before the next step. - `dtmf` - send the `digits` as DTMF tones. duration: type: [integer, "null"] description: Pause duration in milliseconds. Relevant when `action=pause`. digits: type: [string, "null"] description: DTMF digit string to send. Relevant when `action=dtmf`. Forwarding: type: object required: - id - group_id - group_name - type - gender - job_title - first_name - last_name - email - competence - activation_keywords - destination_type - phone_number - sip_uri - sip_username - sip_refer_target - has_sip_credentials - active - fallback - timeout - dtmf_sequence - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } group_id: { type: string, example: "65a1234567890abcdef01234" } group_name: { type: [string, "null"], example: "Escalations" } type: type: [string, "null"] enum: [personal, queue, fallback, null] description: Forwarding kind. `null` if unset. gender: type: string example: "female" description: Empty string means the forwarding's gender is not specified. job_title: { type: string, example: "Head of Support" } first_name: { type: string, example: "Ada" } last_name: { type: string, example: "Lovelace" } email: { type: string, example: "ada@example.com" } competence: { type: string, example: "billing" } activation_keywords: type: array description: | Optional per-forwarding activation keywords. When every active forwarding has at least one custom activation keyword, generic transfer words are not used for forwarding activation. items: type: string maxLength: 80 maxItems: 50 destination_type: type: string enum: [phone, sip, sip_refer] description: Where the call routes - an E.164 phone number, SIP URI, or SIP REFER target. phone_number: type: string example: "+491701234567" description: >- Destination number for `phone` forwarding. May be an empty string when it was omitted at write time; such a target cannot be dialed. sip_uri: type: string description: SIP URI in `sip:user@host` form. Relevant when `destination_type=sip`. example: "sip:forwarding@example.com" sip_username: type: [string, "null"] description: Optional SIP auth username. Relevant when `destination_type=sip`. sip_refer_target: type: [string, "null"] description: SIP REFER target. Relevant when `destination_type=sip_refer`. has_sip_credentials: type: [boolean, "null"] description: Whether SIP credentials are stored for this forwarding. `sip_password` is never returned. identity_source: type: [string, "null"] enum: [employee, null] description: >- Read-only. `employee` marks a forwarding created by the employee import whose identity and destination follow the employee record; updates to those fields are rejected - edit the employee instead. `null` for hand-managed records. active: { type: boolean, default: true } fallback: { type: boolean, default: false } timeout: type: integer minimum: 10 maximum: 60 default: 30 description: Ring timeout in seconds before the call gives up on this target. dtmf_sequence: type: [array, "null"] description: Optional DTMF steps to run after the callee picks up. `null` if none configured. items: { $ref: "#/components/schemas/DtmfSequenceItem" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ForwardingCreateFields: type: object required: [group_id] additionalProperties: false properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } type: type: string enum: [personal, queue, fallback] destination_type: type: string enum: [phone, sip, sip_refer] default: phone gender: type: string description: Optional gender value. Omit it or send an empty string when not specified. job_title: { type: string } first_name: { type: string } last_name: { type: string } email: { type: string } competence: { type: string } activation_keywords: type: array items: { type: string, maxLength: 80 } maxItems: 50 phone_number: { type: string, example: "+12025550123" } sip_uri: type: string pattern: "^sip:" description: SIP URI. Required when `destination_type=sip`. example: "sip:forwarding@example.com" sip_username: type: [string, "null"] description: Optional SIP auth username. Only allowed when `destination_type=sip`. sip_password: type: [string, "null"] format: password description: Optional SIP auth password. Only allowed when `destination_type=sip`; never returned in responses. sip_refer_target: type: string minLength: 1 pattern: "^\\S+$" description: SIP REFER target. Required when `destination_type=sip_refer`. active: { type: boolean } fallback: { type: boolean } timeout: { type: integer, minimum: 10, maximum: 60 } dtmf_sequence: type: array items: { $ref: "#/components/schemas/DtmfSequenceItem" } ForwardingCreate: description: | When `destination_type` is `sip`, `sip_uri` must be provided. When `destination_type` is `sip_refer`, `sip_refer_target` must be provided. For a usable `phone` destination, provide a non-empty `phone_number`. The current validator permits it to be omitted or empty, but such a forwarding cannot be dialed. oneOf: - title: Phone destination allOf: - $ref: "#/components/schemas/ForwardingCreateFields" - type: object properties: destination_type: { type: string, const: phone } not: anyOf: - properties: { sip_username: {} } required: [sip_username] - properties: { sip_password: {} } required: [sip_password] - properties: { sip_refer_target: {} } required: [sip_refer_target] - title: SIP destination allOf: - $ref: "#/components/schemas/ForwardingCreateFields" - type: object required: [destination_type, sip_uri] properties: destination_type: { type: string, const: sip } not: properties: { sip_refer_target: {} } required: [sip_refer_target] - title: SIP REFER destination allOf: - $ref: "#/components/schemas/ForwardingCreateFields" - type: object required: [destination_type, sip_refer_target] properties: destination_type: { type: string, const: sip_refer } not: anyOf: - properties: { sip_username: {} } required: [sip_username] - properties: { sip_password: {} } required: [sip_password] - properties: type: { const: personal } required: [type] ForwardingUpdateFields: type: object minProperties: 1 additionalProperties: false properties: type: type: string enum: [personal, queue, fallback] destination_type: type: string enum: [phone, sip, sip_refer] gender: type: string description: Send an empty string to clear the value; omit the property to preserve it. job_title: { type: string } first_name: { type: string } last_name: { type: string } email: { type: string } competence: { type: string } activation_keywords: type: array items: { type: string, maxLength: 80 } maxItems: 50 phone_number: { type: string } sip_uri: type: string pattern: "^sip:" sip_username: type: [string, "null"] description: Optional SIP auth username. Only allowed when the effective `destination_type` is `sip`. sip_password: type: [string, "null"] format: password description: Optional SIP auth password. Only allowed when the effective `destination_type` is `sip`; never returned in responses. sip_refer_target: type: string minLength: 1 pattern: "^\\S+$" description: SIP REFER target. Required when changing `destination_type` to `sip_refer`. active: { type: boolean } fallback: { type: boolean } timeout: { type: integer, minimum: 10, maximum: 60 } dtmf_sequence: type: array items: { $ref: "#/components/schemas/DtmfSequenceItem" } ForwardingUpdate: description: | Partial update - include only the fields you want to change. `group_id` cannot be changed via this endpoint. oneOf: - title: Other fields or stored SIP credentials allOf: - $ref: "#/components/schemas/ForwardingUpdateFields" - type: object not: anyOf: - properties: { destination_type: {} } required: [destination_type] - properties: { sip_refer_target: {} } required: [sip_refer_target] - title: Phone destination allOf: - $ref: "#/components/schemas/ForwardingUpdateFields" - type: object required: [destination_type] properties: destination_type: { type: string, const: phone } not: anyOf: - properties: { sip_username: {} } required: [sip_username] - properties: { sip_password: {} } required: [sip_password] - properties: { sip_refer_target: {} } required: [sip_refer_target] - title: SIP destination allOf: - $ref: "#/components/schemas/ForwardingUpdateFields" - type: object required: [destination_type] properties: destination_type: { type: string, const: sip } not: properties: { sip_refer_target: {} } required: [sip_refer_target] - title: SIP REFER destination allOf: - $ref: "#/components/schemas/ForwardingUpdateFields" - type: object required: [destination_type, sip_refer_target] properties: destination_type: { type: string, const: sip_refer } not: anyOf: - properties: { sip_username: {} } required: [sip_username] - properties: { sip_password: {} } required: [sip_password] - properties: type: { const: personal } required: [type] PageForwarding: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Forwarding" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Glossary ────────────────────────────────────────────────────────── Glossary: type: object required: - id - group_id - group_name - abbreviation - name - description - aliases - active - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } group_id: { type: string, example: "65a1234567890abcdef01234" } group_name: { type: [string, "null"], example: "Product terminology" } abbreviation: { type: string, example: "API" } name: { type: string, example: "Application Programming Interface" } description: type: string example: "A set of definitions and protocols for building software." aliases: type: array items: { type: string } example: ["A P I", "Api"] description: >- Spoken forms of the term as the speech recognizer tends to write them. Used to recognize the term in a call and to correct the transcript; never spoken back to the caller. active: { type: boolean, default: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } GlossaryCreate: type: object required: [group_id, name] additionalProperties: false example: group_id: "65a1234567890abcdef01234" name: "Application Programming Interface" abbreviation: "API" aliases: ["A P I"] properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } name: type: string minLength: 1 maxLength: 200 description: Whitespace is trimmed; the trimmed name must be at least 1 character. abbreviation: { type: string, maxLength: 100, default: "" } description: { type: string, maxLength: 5000, default: "" } aliases: type: array maxItems: 20 items: { type: string, minLength: 1, maxLength: 100 } default: [] description: >- Spoken forms of the term as the speech recognizer tends to write them (for example `Michel` for `Mitel`). Each entry is trimmed; empty and duplicate entries are dropped. active: { type: boolean, default: true } GlossaryUpdate: type: object minProperties: 1 additionalProperties: false description: | Partial update - include only the fields you want to change. `group_id` cannot be changed via this endpoint. example: description: "A set of definitions and protocols for building software." properties: name: type: string minLength: 1 maxLength: 200 description: Whitespace is trimmed; the trimmed name must be at least 1 character. abbreviation: { type: string, maxLength: 100 } description: { type: string, maxLength: 5000 } aliases: type: array maxItems: 20 items: { type: string, minLength: 1, maxLength: 100 } description: Replaces the whole list. Send `[]` to clear it. active: { type: boolean } PageGlossary: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Glossary" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Pronunciation ────────────────────────────────────────────────────── Pronunciation: type: object required: - id - group_id - group_name - word - replacement - ipa - spell_out - rate - case_sensitive - active - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } group_id: { type: string, example: "65a1234567890abcdef01234" } group_name: { type: [string, "null"], example: "Brand names" } word: type: string minLength: 1 maxLength: 200 pattern: "^[^\\s<>]+$" example: "smao" description: The written single-token form to match in generated speech. Whitespace and angle brackets (`<`, `>`) are rejected. replacement: type: string example: "ess-mao" description: Phonetic respelling spoken in place of the word, on every voice. An entry holds a replacement or `ipa`, not both; entries saved before 2026-10-09 may still hold both (v2 voices then use the IPA, v1 voices the replacement). ipa: type: string example: "ɛsmaʊ" description: International Phonetic Alphabet transcription. Applied on v2 voices; ignored on v1 voices. spell_out: type: boolean default: false description: When true, the matched word is spoken letter by letter - `API` is spoken as `A`, `P`, `I`. rate: type: [string, "null"] enum: [null, slow, medium, fast] default: null description: Speaking-rate override applied when the word is spoken. `null` inherits the voice/request speed; `medium` is explicit normal speed. Applied on v2 voices; ignored on v1 voices. case_sensitive: { type: boolean, default: false } active: { type: boolean, default: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } PronunciationCreate: type: object required: [group_id, word] additionalProperties: false description: | At least one effective option is required: nonblank replacement, nonblank valid IPA, spell_out enabled, or an explicit supported rate. This also applies to inactive entries. Nonempty whitespace-only replacement or IPA values are rejected; meaningful text is preserved exactly. `replacement` and `ipa` are alternatives (v1 voices speak the replacement, v2 voices the IPA): at most one of them may be non-empty. anyOf: - type: object required: [replacement] properties: replacement: { type: string, pattern: "\\S" } - type: object required: [ipa] properties: ipa: { type: string, pattern: "\\S" } - type: object required: [spell_out] properties: spell_out: { type: boolean, const: true } - type: object required: [rate] properties: rate: { type: string, enum: [slow, medium, fast] } example: group_id: "65a1234567890abcdef01234" word: "SMAO" spell_out: true properties: group_id: { type: string, pattern: "^[a-f0-9]{24}$" } word: type: string minLength: 1 maxLength: 200 pattern: "^[^\\s<>]+$" description: | The written single-token form to match. Whitespace and angle brackets (`<`, `>`) are rejected. replacement: type: string pattern: "^(?!\\s+$)" maxLength: 1000 default: "" description: Phonetic respelling. Angle brackets (`<`, `>`) are rejected. Must be empty when `spell_out` is true or `ipa` is set. ipa: type: string pattern: "^(?!\\s+$)" maxLength: 200 default: "" description: International Phonetic Alphabet transcription. Applied on v2 voices; ignored on v1 voices. Must be empty when `spell_out` is true or `replacement` is set. spell_out: type: boolean default: false description: When true, the matched word is spoken letter by letter - `API` is spoken as `A`, `P`, `I`. rate: type: [string, "null"] enum: [null, slow, medium, fast] default: null description: Speaking-rate override. `null` inherits the voice/request speed; `medium` is explicit normal speed. case_sensitive: { type: boolean, default: false } active: { type: boolean, default: true } PronunciationUpdate: type: object minProperties: 1 additionalProperties: false description: | Partial update - include only the fields you want to change. `group_id` cannot be changed via this endpoint. The final merged state must not combine `spell_out: true` with a non-empty `replacement` or `ipa`. An update that sets `replacement` or `ipa` must leave only one of them non-empty (send `""` to clear the other); entries saved with both before 2026-10-09 keep working and accept updates that set neither. It must retain at least one effective option: nonblank replacement, valid nonblank IPA, spell_out enabled, or an explicit supported rate. Clearing the last effective option is rejected, including for inactive entries. Existing invalid entries remain readable and deletable but must be corrected before saving. Concurrent changes to the validated fields return 409; reload before retrying. example: case_sensitive: true properties: word: type: string minLength: 1 maxLength: 200 pattern: "^[^\\s<>]+$" description: | The written single-token form to match. Whitespace and angle brackets (`<`, `>`) are rejected. replacement: type: string pattern: "^(?!\\s+$)" maxLength: 1000 description: Angle brackets (`<`, `>`) are rejected. Must be empty when the final `spell_out` state is true. When set, the final `ipa` must be empty. ipa: type: string pattern: "^(?!\\s+$)" maxLength: 200 description: International Phonetic Alphabet transcription. Applied on v2 voices; ignored on v1 voices. Must be empty when the final `spell_out` state is true. When set, the final `replacement` must be empty. spell_out: type: boolean description: When true, the matched word is spoken letter by letter - `API` is spoken as `A`, `P`, `I`. rate: type: [string, "null"] enum: [null, slow, medium, fast] description: Speaking-rate override. Send `null` to return to the default inherited speed, or `medium` for an explicit normal-speed span. case_sensitive: { type: boolean } active: { type: boolean } PagePronunciation: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Pronunciation" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Calendars ───────────────────────────────────────────────────────── CalendarSlot: type: object required: [from, to] additionalProperties: false properties: from: type: string pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" example: "09:00" to: type: string pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" example: "17:00" CalendarDaySchedule: type: object required: [is_open, slots] additionalProperties: false properties: is_open: { type: boolean } slots: type: array items: { $ref: "#/components/schemas/CalendarSlot" } CalendarWeeklySchedule: type: object description: | Weekly opening-hours template. Each weekday is optional. Omitted weekdays (`mon`–`fri`) default to open 09:00–17:00; omitted weekend days (`sat`–`sun`) default to closed with no slots. To close an omitted weekday explicitly, send `{ "is_open": false, "slots": [] }`. Keys use 3-letter day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. additionalProperties: false properties: mon: { $ref: "#/components/schemas/CalendarDaySchedule" } tue: { $ref: "#/components/schemas/CalendarDaySchedule" } wed: { $ref: "#/components/schemas/CalendarDaySchedule" } thu: { $ref: "#/components/schemas/CalendarDaySchedule" } fri: { $ref: "#/components/schemas/CalendarDaySchedule" } sat: { $ref: "#/components/schemas/CalendarDaySchedule" } sun: { $ref: "#/components/schemas/CalendarDaySchedule" } CalendarOverride: type: object required: [date, type] additionalProperties: false description: | Date-specific override - either closes the calendar for the day (`type: closed`) or replaces the day's slots (`type: modified`). `slots` is required (and must contain at least one entry) when `type` is `modified`; it is optional/omitted when `type` is `closed`. properties: date: type: string pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" example: "2026-12-24" description: >- Date in `YYYY-MM-DD` shape. Send a real calendar date; current request validation checks the shape but not month/day validity. type: type: string enum: [closed, modified] reason: type: [string, "null"] example: "Christmas Eve" slots: type: array description: | Always present in responses. On request input, required and non-empty when `type: modified`. For `type: closed`, supplied values are accepted, stored, and returned, but ignored when effective opening hours are computed; omit them to receive `[]`. items: { $ref: "#/components/schemas/CalendarSlot" } CalendarSchedule: type: object required: [id, type, weekly_schedule, overrides] additionalProperties: false description: | Schedule as returned in responses. `id` is always present and server-assigned. For write payloads see `CalendarScheduleCreate` and `CalendarScheduleUpdate`. properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } type: type: string enum: [opening_hours, service_hours, office_hours, operating_hours] weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" } overrides: type: array items: { $ref: "#/components/schemas/CalendarOverride" } CalendarScheduleCreate: type: object required: [type] additionalProperties: false description: | Schedule shape for create requests. `id` is server-assigned on POST and therefore omitted; for the response shape see `CalendarSchedule`. properties: type: type: string enum: [opening_hours, service_hours, office_hours, operating_hours] weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" } overrides: type: array items: { $ref: "#/components/schemas/CalendarOverride" } CalendarScheduleUpdate: type: object required: [type] additionalProperties: false description: | Schedule shape accepted when replacing `schedules` on PATCH. Include the response `id` to preserve an existing schedule's identifier; omit it to let the server assign a new identifier. properties: id: type: string pattern: "^[a-f0-9]{24}$" type: type: string enum: [opening_hours, service_hours, office_hours, operating_hours] weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" } overrides: type: array items: { $ref: "#/components/schemas/CalendarOverride" } Calendar: type: object required: - id - name - timezone - schedules - created_at - updated_at properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" } name: { type: string, example: "Main Office" } timezone: { type: string, example: "Europe/Berlin" } schedules: type: array items: { $ref: "#/components/schemas/CalendarSchedule" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CalendarCreate: type: object required: [name] additionalProperties: false example: name: "Main Office" timezone: "Europe/Berlin" properties: name: type: string minLength: 1 maxLength: 100 description: Whitespace is trimmed; the trimmed name must be at least 1 character. timezone: type: string minLength: 1 maxLength: 100 default: "Europe/Berlin" description: IANA timezone string. Validated server-side. schedules: type: array items: { $ref: "#/components/schemas/CalendarScheduleCreate" } default: [] CalendarUpdate: type: object minProperties: 1 additionalProperties: false description: | Partial update - include only the fields you want to change. Passing `schedules` replaces the full schedules array for this calendar. example: name: "Main Office Berlin" properties: name: type: string minLength: 1 maxLength: 100 description: Whitespace is trimmed; the trimmed name must be at least 1 character. timezone: { type: string, minLength: 1, maxLength: 100 } schedules: type: array items: { $ref: "#/components/schemas/CalendarScheduleUpdate" } PageCalendar: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Calendar" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Calls ───────────────────────────────────────────────────────────── RecordingSummary: type: object required: [available] properties: available: { type: boolean } format: { type: [string, "null"], example: "flac" } mime_type: { type: [string, "null"], example: "audio/flac" } duration_ms: { type: [integer, "null"] } size_bytes: { type: [integer, "null"] } Call: type: object required: - id - direction - source - done - success - created_at properties: id: { type: string } assistant_id: { type: [string, "null"] } assistant_name: { type: [string, "null"] } assistant_phone: { type: [string, "null"] } direction: { type: string, enum: [inbound, outbound] } source: type: string enum: [inbound_phone, outbound_widget, outbound_api, outbound_manual] description: | How the call was originated: - `inbound_phone` - caller dialed in from a real phone number. - `outbound_widget` - chat widget kicked off an outbound call. - `outbound_api` - Public API (POST /calls) triggered the call. - `outbound_manual` - dashboard user manually triggered the call. phone_number: { type: [string, "null"], description: "Remote party phone number." } duration_seconds: { type: [integer, "null"] } done: { type: boolean } success: { type: boolean } language: { type: [string, "null"] } started_at: { type: [string, "null"], format: date-time } ended_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } CallTranscriptMessage: type: object required: [role, content] properties: role: { type: string, example: "assistant" } content: { type: string, description: "On an assistant with contextual_asr_correction_enabled, caller turns carry the corrected text the assistant actually answered, not the raw recogniser output." } timestamp: { type: [string, "null"], format: date-time } tool_calls: type: array description: Only on an assistant turn that called tools; its `content` is usually empty. Each call's result follows as a `tool` turn carrying the call's `id` as `tool_call_id`. items: type: object required: [id, name, arguments] properties: id: { type: [string, "null"] } name: { type: [string, "null"], description: "The tool the model called." } arguments: { type: [string, "null"], description: "The arguments as JSON text, exactly as the model sent them. Parse it yourself; model output is not guaranteed to be valid JSON." } tool_call_id: { type: string, description: "Only on a `tool` turn: the `id` of the call whose result this turn carries." } CallAnalysis: type: object required: [id, status] properties: id: { type: string } name: { type: [string, "null"] } status: { type: string, example: "completed" } body_result: description: Raw analysis result. Shape depends on the configured analysis. oneOf: - type: object - type: string - type: "null" started_at: { type: [string, "null"], format: date-time } completed_at: { type: [string, "null"], format: date-time } CallNote: type: object required: [id, content, created_at] properties: id: { type: string } content: { type: string } author_id: { type: [string, "null"] } author_name: { type: [string, "null"] } created_at: { type: string, format: date-time } CallDetail: allOf: - $ref: "#/components/schemas/Call" - type: object required: [transcript, analyses, notes, recording] properties: transcript: type: array items: { $ref: "#/components/schemas/CallTranscriptMessage" } analyses: type: array items: { $ref: "#/components/schemas/CallAnalysis" } notes: type: array items: { $ref: "#/components/schemas/CallNote" } recording: { $ref: "#/components/schemas/RecordingSummary" } mailbox_detection: $ref: "#/components/schemas/MailboxDetectionResult" PageCall: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Call" } meta: { $ref: "#/components/schemas/PageMeta" } MailboxDetectionResult: type: [object, "null"] description: >- Outcome of opt-in outbound detection, or null when not requested. Poll getCall until done is true before scheduling another call. Terminal calls never report pending detection. Detection failures leave the call running; they are not proof that a machine answered. Configured analyses and post-call actions continue independently of detection. required: [status, category, reason, greeting, decided_at_ms] properties: status: type: string enum: [pending, completed, failed, skipped] description: >- pending means detection has not finished; completed means a valid classification exists; failed means detection could not complete reliably; skipped means the call ended, detection was interrupted before classification, or the assistant language is not supported. Completed outcomes are immutable. category: type: [string, "null"] enum: [human, machine-ivr, machine-vm, machine-unavailable, uncertain, null] description: >- Present only for completed detection. machine-vm permits a voicemail message; machine-ivr is a menu or call-screening assistant; machine-unavailable is an announcement without a message option. All three machine categories request assistant hangup after the outcome is saved. human and uncertain leave the conversation running. reason: type: [string, "null"] maxLength: 200 description: >- Short classification rationale for completed detection. Otherwise a generic reason: detection_failed, detection_skipped, or language_not_supported when detection does not yet support the assistant language. Raw provider errors are not returned. greeting: type: [string, "null"] description: Transcribed greeting supporting a completed verdict; null without a verdict. decided_at_ms: type: [integer, "null"] minimum: 0 description: Milliseconds from first received caller audio to the classification decision; null when no decision exists. This is not a hangup timestamp. examples: - null - status: pending category: null reason: null greeting: null decided_at_ms: null - status: completed category: machine-vm reason: Voicemail instructions permit leaving a message. greeting: Bitte hinterlassen Sie eine Nachricht nach dem Signalton. decided_at_ms: 10400 - status: failed category: null reason: detection_failed greeting: null decided_at_ms: null - status: skipped category: null reason: detection_skipped greeting: null decided_at_ms: null - status: skipped category: null reason: language_not_supported greeting: null decided_at_ms: null TriggerOutboundFields: type: object additionalProperties: false properties: assistant_id: type: string pattern: "^[a-f0-9]{24}$" example: "65a1234567890abcdef01234" description: The assistant that will make the call. assistant_phone: type: string pattern: "^\\+\\d{6,20}$" example: "+491701234567" description: >- Alternative to `assistant_id` - look up the assistant by its phone number in E.164 format (leading `+` followed by 6–20 digits). to: type: string pattern: "^\\+\\d{6,20}$" example: "+491701234999" description: >- Destination phone number in E.164 format (leading `+` followed by 6–20 digits). target_phone: type: string pattern: "^\\+\\d{6,20}$" example: "+12025550123" description: Legacy alias for `to`. Same E.164 format. details: type: string maxLength: 1000 description: Optional free-text context passed to the assistant prompt. mailbox_detection: type: boolean default: false description: >- Opt-in one-shot detection using the first eight seconds of raw caller audio. A machine verdict (voicemail, IVR/call screening or an unavailable announcement) requests hangup only after its outcome is saved. Human, uncertain and failed detection leave the call running. Poll getCall for mailbox_detection and wait for done before retrying. Existing post-call analyses and actions still run. Default false. Detection currently supports German (de) assistants; for any other assistant language the call proceeds unchanged and reports skipped with reason language_not_supported. Outbound creation does not provide idempotency: do not blindly retry an ambiguous POST result. TriggerOutbound: description: | Provide **exactly one** of `assistant_id` or `assistant_phone`, and **exactly one** of `to` or `target_phone` (`target_phone` is a legacy alias). oneOf: - title: Assistant ID + to allOf: - $ref: "#/components/schemas/TriggerOutboundFields" - type: object required: [assistant_id, to] - title: Assistant ID + target_phone allOf: - $ref: "#/components/schemas/TriggerOutboundFields" - type: object required: [assistant_id, target_phone] - title: Assistant phone + to allOf: - $ref: "#/components/schemas/TriggerOutboundFields" - type: object required: [assistant_phone, to] - title: Assistant phone + target_phone allOf: - $ref: "#/components/schemas/TriggerOutboundFields" - type: object required: [assistant_phone, target_phone] # ── Chats ───────────────────────────────────────────────────────────── Chat: type: object description: A website-widget chat from the dashboard Inbox. required: - id - widget_id - widget_name - assistant_id - assistant_name - language - status - current_responder - needs_human - human_request_reason - human_requested_at - human_takeover_at - visitor - page_url - origin - last_message_preview - last_message_author - last_message_at - created_at - closed_at properties: id: type: string description: The chat's UUID, as the dashboard Inbox shows it. example: "3f2b8c1e-9a4d-4c6b-8f1e-2d7a5b9c0e41" widget_id: { type: [string, "null"], example: "65f8fa6f1a9d8e0012ab34ce" } widget_name: { type: [string, "null"], example: "Website chat" } assistant_id: type: [string, "null"] description: The assistant that answers while the AI responds. Null for chats without one. example: "65f8fa6f1a9d8e0012ab34cd" assistant_name: { type: [string, "null"], example: "Lea" } language: { type: string, example: "de" } status: { type: string, enum: [open, closed] } current_responder: type: string enum: [ai, human] description: Who answers the visitor now. Moves from `ai` to `human` on handoff or takeover, never back. needs_human: type: boolean description: "True while the chat waits for a reply from the team: the visitor was handed over or wrote in a chat the team answers, and no team member has replied or taken over since." human_request_reason: type: [string, "null"] enum: [explicit_request, frustration, null] description: Why the chat was handed to the team. human_requested_at: { type: [string, "null"], format: date-time } human_takeover_at: type: [string, "null"] format: date-time description: When a team member took the chat over. visitor: type: object required: [name, email] properties: name: type: [string, "null"] description: As the visitor or the embedding website provided it. example: "Anna Schmidt" email: type: [string, "null"] format: email description: As the visitor or the embedding website provided it. example: "anna.schmidt@example.org" page_url: type: [string, "null"] description: The page the chat started on - origin and path, without the query string. example: "https://www.example.org/kontakt" origin: type: [string, "null"] description: The website the chat started on. example: "https://www.example.org" last_message_preview: type: [string, "null"] description: The last visitor, AI, or team message, cut to at most 280 characters. last_message_author: { type: [string, "null"], enum: [visitor, ai, human, null] } last_message_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } closed_at: { type: [string, "null"], format: date-time } ChatMessage: type: object required: [id, sequence, author, author_name, content, created_at] properties: id: { type: string, example: "65f8fa6f1a9d8e0012ab34cf" } sequence: type: integer minimum: 1 description: Position in the chat. Gaps are normal - internal messages are numbered too. author: type: string enum: [visitor, ai, human, system] description: | - `visitor` - the website visitor. - `ai` - the assistant. - `human` - the team, including a welcome message set for team chats. - `system` - a notice shown in the widget, such as a team member joining. author_name: type: [string, "null"] description: The name the visitor saw, if any. For team replies this is the team member's name, or their email address when no name is set. content: { type: string } created_at: { type: string, format: date-time } ChatDetail: allOf: - $ref: "#/components/schemas/Chat" - type: object required: [messages, messages_truncated] properties: messages: type: array description: The first 100 messages the visitor saw, oldest first. items: { $ref: "#/components/schemas/ChatMessage" } messages_truncated: type: boolean description: True when the chat has more messages than `messages` holds. Rare in practice. PageChat: type: object required: [data, meta] properties: data: type: array items: { $ref: "#/components/schemas/Chat" } meta: { $ref: "#/components/schemas/PageMeta" } # ── Statistics ──────────────────────────────────────────────────────── StatisticsWeekYear: type: object required: [week, year] properties: week: { type: integer, example: 17 } year: { type: integer, example: 2026 } StatisticsAvailableAssistant: type: object required: [id, name] properties: id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" } name: { type: string, example: "Inbound Receptionist" } StatisticsDailyCallCount: type: object required: [date, count] properties: date: { type: string, example: "2026-04-22", description: "Start date of the counted calls (YYYY-MM-DD), Europe/Berlin timezone." } count: { type: integer, example: 12 } StatisticsMonthlyBreakdown: type: object required: [call_count, overall_call_seconds, average_calls_per_day, daily_call_counts] properties: call_count: type: integer example: 240 description: >- Completed, non-error calls that started in the selected ISO week, including calls that count 0 seconds. overall_call_seconds: type: integer example: 60000 description: >- Sum of billable seconds, rated the way SMAO invoices calls (see `/statistics/usage`): recorded assistant session time with a 60-second minimum per call, cut at a completed SIP REFER hand-off. Calls without recorded session time, such as bypassed or policy-rejected calls, contribute 0 seconds even though they remain included in `call_count`. average_calls_per_day: type: number example: 12 description: >- Calls divided by the number of days that had calls (not all calendar days in the month), rounded to the nearest whole number. daily_call_counts: type: array items: { $ref: "#/components/schemas/StatisticsDailyCallCount" } StatisticsSummary: type: object required: [available_weeks, available_assistants] properties: available_weeks: type: array description: Every ISO (week, year) pair, in German time (Europe/Berlin), in which at least one completed call started. items: { $ref: "#/components/schemas/StatisticsWeekYear" } available_assistants: type: array description: >- Every assistant currently in the organization. The sentinel `all` is NOT included - clients may always pass `assistant_id=all` to `/usage`. items: { $ref: "#/components/schemas/StatisticsAvailableAssistant" } StatisticsUsage: type: object required: - period - assistant_id - monthly - weekly_call_counts - weekly_call_seconds - hourly_call_counts_last_24h properties: period: { $ref: "#/components/schemas/StatisticsWeekYear" } assistant_id: oneOf: - type: string enum: [all] - type: string pattern: "^[a-f0-9]{24}$" description: Echoes the filter the client sent. monthly: type: object description: >- Breakdown for the month or months intersecting the selected ISO week, keyed by month name from the server runtime locale (for example `April`). Only months with at least one completed, non-error call appear. Treat keys as display labels, not stable identifiers. additionalProperties: { $ref: "#/components/schemas/StatisticsMonthlyBreakdown" } weekly_call_counts: type: array description: >- 7 integers, Mon..Sun by call start time, for the selected (week, year). Unlike `monthly.*.call_count`, this array counts only calls with more than 0 billable seconds, that is calls with recorded assistant session time. The service returns an empty array when no calls match. minItems: 0 maxItems: 7 items: { type: integer } weekly_call_seconds: type: array description: >- 7 integers of billable seconds (rated like `monthly.*.overall_call_seconds`), Mon..Sun by call start time, for the selected week. The service returns an empty array when no calls match. minItems: 0 maxItems: 7 items: { type: integer } hourly_call_counts_last_24h: type: array description: >- 24 integers for the last 24 hours ending at request time (index 0 = 23–24h ago, index 23 = 0–1h ago), by call start time. Counts only calls with more than 0 billable seconds, like `weekly_call_counts`. This array is organization-wide and independent of the `week`, `year`, and `assistant_id` filters. minItems: 24 maxItems: 24 items: { type: integer } StatisticsAssistantEntry: type: object required: - assistant_id - assistant_name - assistant_language - is_deleted - total_calls - total_call_seconds - average_call_seconds - average_calls_per_day - weekly_call_counts - weekly_call_seconds - daily_call_counts properties: assistant_id: type: [string, "null"] pattern: "^[a-f0-9]{24}$" example: "65f8fa6f1a9d8e0012ab34cd" description: >- Null when the call was recorded without an assistant reference (legacy or orphaned calls). assistant_name: { type: string } assistant_language: type: [string, "null"] description: >- Assistant language tag (commonly a 2-letter code like `de`/`en`, or BCP-47 like `de-DE`), or `null` if unset. Format is provider-defined and not validated. is_deleted: type: boolean description: True if the assistant no longer exists in this organization (deleted or moved). total_calls: type: integer description: >- Completed, non-error calls that started in the week, including calls that count 0 seconds. total_call_seconds: type: integer description: >- Billable seconds for the week, rated like `StatisticsMonthlyBreakdown.overall_call_seconds`. Calls without recorded assistant session time contribute 0 seconds but remain included in `total_calls` and `weekly_call_counts`. average_call_seconds: type: number description: >- `total_call_seconds` divided by `total_calls` (calls counting 0 seconds included), rounded to the nearest whole second. average_calls_per_day: type: number description: Mean calls per day across days that had calls, rounded to one decimal place. weekly_call_counts: type: array minItems: 7 maxItems: 7 items: { type: integer } description: >- Mon..Sun completed, non-error call counts by call start time, including calls that count 0 seconds. weekly_call_seconds: type: array minItems: 7 maxItems: 7 items: { type: integer } description: Mon..Sun billable seconds by call start time. daily_call_counts: type: array items: { $ref: "#/components/schemas/StatisticsDailyCallCount" } StatisticsAssistantsUsage: type: object required: [period, total_assistants, total_calls, assistants] properties: period: { $ref: "#/components/schemas/StatisticsWeekYear" } total_assistants: { type: integer } total_calls: { type: integer } assistants: type: array items: { $ref: "#/components/schemas/StatisticsAssistantEntry" } # ── Tools ───────────────────────────────────────────────────────────── Tool: type: object required: - id - tool_key - display_name - description - parameters - execution_type - source - webhook - integration - auth - created_at - updated_at additionalProperties: false properties: id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65f8fa6f1a9d8e0012ab34cd" } tool_key: { type: string, example: "get_opening_hours" } display_name: { type: string, example: "Get Opening Hours" } description: { type: string, example: "Returns the opening hours for the requested weekday." } parameters: type: object additionalProperties: true description: JSON Schema for tool arguments. Customer-defined. execution_type: type: string enum: [webhook] description: >- Public API list/get operations expose webhook tools only. Integration-type tools created in the dashboard are filtered out. source: type: string enum: [custom, template] webhook: oneOf: - { $ref: "#/components/schemas/ToolWebhookConfig" } - { type: "null" } integration: type: "null" description: Always `null` because integration tools are not exposed by this API. auth: oneOf: - { $ref: "#/components/schemas/ToolAuthSummary" } - { type: "null" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ToolWebhookConfig: type: object required: [url] additionalProperties: false properties: url: type: string format: uri pattern: "^https://" example: "https://api.example.com/smao-tools" description: >- Endpoint that receives tool invocations. The executor accepts only HTTPS URLs and rejects `http://localhost` and every other non-HTTPS URL. At invocation time, URLs that resolve to private, loopback, link-local, or cloud-metadata addresses are rejected and redirects are not followed - an HTTPS URL that redirects to a private address will fail. The URL supports the same `@key` and `@key.subfield` placeholders as `body_template`. Substituted values are percent-encoded in path and query components. A substituted path segment that would become `.` or `..` is rejected before sending. GET appends unused, non-null arguments as encoded query parameters; argument names beginning with `_` are omitted. method: type: string enum: [GET, POST, PUT, PATCH, DELETE] default: "POST" headers: type: object additionalProperties: true description: >- Custom HTTP headers. WARNING - do NOT put secrets in headers. Use the `auth` object for bearer tokens / API keys / basic auth. Values in this object are stored and returned as plaintext. Header values support the same `@key` placeholders as `body_template`; header names are literal. A custom header whose name matches the header set by `auth` (case-insensitive, ignoring surrounding whitespace) is ignored, so it cannot override the configured credential. body_template: type: string maxLength: 50000 description: >- Request body template. `@key` reads a call argument; `@key.subfield` reads an own property within an object argument. Missing paths become empty strings. Substitution is single-pass: placeholders inside argument values are not evaluated again. JSON templates are parsed before substitution and resolved structurally. Object keys are literal; each value stays in its authored position and cannot create or override sibling keys. A placeholder inside a JSON string (`{"n":"@count"}` or `"id-@count"`) always produces text: objects and arrays become their JSON text and null becomes an empty string. A bare placeholder (`{"n":@count}`) inserts the argument as a JSON value: numbers, booleans, null, objects and arrays keep their type, a string that is itself one JSON value (such as `"42"`) is inserted as that value, and any other string is inserted as a JSON string. A literal `user@example.com` is preserved. JSON bodies are serialized with a default `Content-Type: application/json`, unless a content type is explicitly configured. A template that still does not parse as JSON after quoting bare placeholders uses raw-text substitution; raw text has no JSON structure guarantee. `@_context` exposes only these fixed scalar fields (unavailable fields are null): `callId`, `sessionId`, `language`, `callerPhone`, `organizationId`, `instanceId`, `assistantId`, `organizationName`, `assistantName`. Raw instance, organization and assistant documents are inaccessible; paths such as `@_context.instance.secretKey` become empty strings. An argument named `_context` cannot replace this projection. GET requests have no body. timeout_ms: type: integer minimum: 500 maximum: 60000 retry_count: type: integer minimum: 0 maximum: 5 ToolAuthSummary: type: object required: [type, has_secret] additionalProperties: false description: >- RESPONSE-ONLY auth shape. Never contains the stored secret value; the API surfaces `has_secret` as the only signal of whether a credential is configured. To rotate, PATCH the tool with a new `auth` input. properties: type: type: string enum: [apiKey, bearer, basic] header_name: type: [string, "null"] description: Custom header name for `apiKey` / `bearer` auth types. username: type: [string, "null"] description: Username for `basic` auth. has_secret: type: boolean description: >- True when an auth secret / password is stored. The actual value is never returned by the API - submit a new value on PATCH to rotate. ToolAuthInput: description: | Auth credentials sent on POST/PATCH. Shape varies by `type`. Note: `secret` and `password` are write-only; responses use `ToolAuthSummary`. oneOf: - title: ApiKey type: object additionalProperties: false required: [type, header_name, secret] properties: type: { type: string, const: apiKey } header_name: { type: string, minLength: 1 } secret: { type: string, minLength: 1, writeOnly: true } - title: Bearer type: object additionalProperties: false required: [type, header_name, secret] properties: type: { type: string, const: bearer } header_name: { type: string, minLength: 1 } secret: { type: string, minLength: 1, writeOnly: true } - title: Basic type: object additionalProperties: false required: [type, username, password] properties: type: { type: string, const: basic } username: { type: string, minLength: 1 } password: { type: string, minLength: 1, writeOnly: true } discriminator: propertyName: type ToolCreate: type: object required: [tool_key, display_name, execution_type, webhook] additionalProperties: false example: tool_key: "get_opening_hours" display_name: "Get Opening Hours" execution_type: webhook webhook: url: "https://api.example.com/smao-tools" properties: tool_key: type: string pattern: "^[a-z][a-z0-9_]*$" minLength: 1 maxLength: 80 example: "get_opening_hours" display_name: type: string minLength: 1 maxLength: 200 description: Leading/trailing whitespace is trimmed. description: type: string default: "" maxLength: 1000 execution_type: type: string enum: [webhook] description: >- v1 accepts `webhook` only. Integration-type tools must be created via the dashboard (OAuth flow). parameters: type: object additionalProperties: true description: JSON Schema defining the tool arguments. webhook: { $ref: "#/components/schemas/ToolWebhookConfig" } auth: { $ref: "#/components/schemas/ToolAuthInput" } ToolUpdate: type: object minProperties: 1 additionalProperties: false description: >- Partial update - include only fields you want to change. `tool_key` and `execution_type` are immutable. Changing `webhook.url` of a tool with a stored credential requires `auth` with that credential. example: display_name: "Get Current Opening Hours" properties: display_name: type: string minLength: 1 maxLength: 200 description: Leading/trailing whitespace is trimmed. description: type: string maxLength: 1000 parameters: type: object additionalProperties: true webhook: { $ref: "#/components/schemas/ToolWebhookConfig" } auth: { $ref: "#/components/schemas/ToolAuthInput" } PageTool: type: object required: [data, meta] additionalProperties: false properties: data: type: array items: { $ref: "#/components/schemas/Tool" } meta: { $ref: "#/components/schemas/PageMeta" } ```` # Distributor API v1 OpenAPI specification (https://api.smao.ai/api/distributor-docs/v1/openapi.yaml) ````yaml openapi: 3.0.3 info: title: SMAO Distributor API v1 version: 1.0.0 description: | SMAO's maintained Distributor API for Synaxon and other partner-based distribution integrations. The Distributor API lets you manage a 3-tier reseller hierarchy on the shared SMAO instance: **Distributor → Partners (MSPs) → Customer Organizations** Your API key is tied to a **partnerCode** (e.g. "acme") and every request is automatically scoped to the data that belongs to your distributor account. ## Authentication Every request (except the health check) requires a Bearer token in the **Authorization** header: ``` Authorization: Bearer ``` Example with curl: ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ https://api.smao.ai/api/distributor/v1/partners ``` If the key is missing, invalid, or expired, the API returns **401**. A key whose metadata does not authorize Distributor API v1 returns **403** with `API_VERSION_NOT_ALLOWED`. Missing endpoint permissions also return **403**. ## Endpoint Permissions Each API key carries a set of permission scopes. An endpoint called without its scope returns **403** with `MISSING_PERMISSION`. The scopes are `organizations:read`, `organizations:write`, `partners:read`, `partners:write` and `usage:read`. | Endpoint | Required permission | |---|---| | `GET /health` | *(public - no key required)* | | `GET /organizations` | `organizations:read` | | `POST /organizations` | `organizations:write` | | `DELETE /organizations/{organizationId}` | `organizations:write` | | `GET /organizations/{organizationId}` | `organizations:read` | | `PATCH /organizations/{organizationId}` | `organizations:write` | | `GET /organizations/{organizationId}/billing-summary` | `usage:read` | | `PATCH /organizations/{organizationId}/plan` | `organizations:write` | | `GET /organizations/{organizationId}/usage` | `usage:read` | | `GET /partners` | `partners:read` | | `POST /partners` | `partners:write` | | `DELETE /partners/{partnerId}` | `partners:write` | | `GET /partners/{partnerId}` | `partners:read` | | `PATCH /partners/{partnerId}` | `partners:write` | | `PATCH /partners/{partnerId}/plan` | `partners:write` | | `GET /partners/{partnerId}/usage` | `usage:read` | | `GET /plans` | `organizations:read` | | `GET /usage` | `usage:read` | ## Rate Limits Each Distributor API key is limited by its configured **requestsPerMinute** value. Keys default to **60 requests per minute**. Authenticated responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` headers. Rate-limited responses return **429** with `RATE_LIMITED`. If the shared rate-limit store is unavailable, the API fails closed with **503** and `RATE_LIMIT_UNAVAILABLE`. ## Quick Start 1. **List available plans** - call the plans endpoint to see which SMAO plans you can assign. 2. **Create a partner (MSP)** - provide a name, your external ID, and the MSP contact's email. SMAO creates an internal organization for the MSP. Trial and invitation setup are best-effort; reconcile dashboard access after provisioning. 3. **Create customer organizations** - provide the **partnerId** from step 2, the customer details, owner email, and the plan to assign. SMAO provisions the organization and creates the customer-owner invitation; email delivery is best-effort. 4. **Query usage and billing** - use the usage and billing-summary endpoints to get the data you need for your own invoicing. ## Hierarchy 1. **Distributor (you)** - holds the API key, manages partners, and can see all data. 2. **Partners (MSPs)** - Managed Service Providers or resellers beneath you. Each partner gets their own SMAO organization (a free trial is attempted when available) and can onboard customers from the SMAO dashboard. Billing is managed by the distributor. 3. **Customer Organizations** - end-customer organizations created either by you via the API (with a required **partnerId**) or by the MSP from the dashboard. Every customer organization is linked to exactly one partner. ## Partner (MSP) Organizations When you create a partner, SMAO automatically: - Creates a new SMAO organization for the MSP with billing managed by the distributor. - Attempts to start a free trial when a trial plan is available. - When **contactEmail** is provided, attempts to create and send an admin invitation. After dashboard access is established, the MSP can create customer organizations directly from the SMAO dashboard. Those customer organizations are automatically linked to the MSP and marked as partner-managed - no API call needed. Billing for the MSP organization is also managed by you - MSPs cannot access Stripe billing, change subscriptions, or manage payment methods on the dashboard. They also cannot delete or leave their organization - that lifecycle is controlled by you via the API. MSPs never get API access. The MSP organization starts on the trial plan, which is capped by call minutes rather than by time. When the MSP wants to keep using its own workspace beyond that, assign it a paid plan with `PATCH /partners/{partnerId}/plan` - the MSP organization is deliberately not addressable through the `/organizations` endpoints, which cover customer organizations only. Usage of the MSP organization is likewise not part of the usage and billing endpoints. ## Customer Restrictions Customer organizations created through this API or by an MSP from the dashboard are partner-managed. On the SMAO dashboard, these end-customers: - Cannot create additional organizations. - Cannot leave or delete the partner-managed organization. - Cannot change, cancel, upgrade, or downgrade subscriptions through the dashboard. - Cannot access Stripe billing self-service such as payment methods or the customer portal. ## Billing Model Billing for customer organizations stays entirely with you. SMAO does not charge end-customers directly. Instead, you assign a plan to each customer organization and SMAO tracks their usage. **All monetary values use the smallest unit of the returned currency** (for example, 6900 = 69.00 EUR). Use the billing-summary endpoint to get everything you need to invoice a customer: - The assigned plan with its **baseFee** (the monthly reference price you invoice). - Per-service usage: how many units were used vs. how many are included in the plan. - Overage: the number of units used beyond the plan's allowance. - Overage cost: overage units multiplied by the per-unit cost, in the smallest unit of the returned currency. - Total overage cost across all services. The billing-summary date range must not exceed **31 days**. You control the billing cycle - we just report usage for the window you specify. ## Revenue Tracking Every customer organization carries your **channelPartner** code and a **partnerId** linking it to the MSP. You can query usage and billing at any level: - **Per customer** - billing-summary or usage for a single organization - **Per MSP** - aggregated usage for all organizations under a partner - **Across all MSPs** - global usage, optionally filtered by partnerId ## Error Handling All error responses follow the same format: ```json { "status": "error", "errorCode": "SOME_CODE", "message": "Human-readable description" } ``` **Common error codes:** | Code | Description | |------|-------------| | MISSING_API_KEY | No Authorization header or empty Bearer token | | INVALID_API_KEY | Key not found or revoked | | API_KEY_EXPIRED | Key has passed its expiration date | | API_VERSION_NOT_ALLOWED | Key is not authorized for Distributor API v1 | | MISSING_PERMISSION | Key lacks the required permission for this endpoint | | RATE_LIMITED | API key exceeded its configured requestsPerMinute limit | | RATE_LIMIT_UNAVAILABLE | Shared rate-limit storage is unavailable; retry shortly | | VALIDATION_ERROR | Request body or query parameter validation failed | | INVALID_ID | The provided ID is not a valid 24-character hex string | | NOT_FOUND | The requested resource does not exist or does not belong to you | | DUPLICATE_EXTERNAL_PARTNER_ID | A partner with this externalPartnerId already exists | | DUPLICATE_EXTERNAL_CUSTOMER_ID | An organization with this externalCustomerId already exists | | PARTNER_NOT_FOUND | The partnerId does not reference an active partner | | PARTNER_HAS_ORGANIZATIONS | Cannot delete a partner that still has linked organizations | | INVALID_PLAN | The selected plan slug/commitment is not available | | PLAN_NOT_FOUND | No subscription found for this organization | | INVALID_DATE_RANGE | Date params are missing, malformed, or from is after to | | DATE_RANGE_TOO_LARGE | Date range exceeds the 31-day maximum | | ORG_CREATION_FAILED | The partner or customer organization could not be created | | INTERNAL_ERROR | Unexpected server error - retry later or contact support | servers: - url: "https://api.smao.ai/api/distributor/v1" description: API server tags: - name: Health description: Health and connectivity endpoints. - name: Partners description: MSP/partner lifecycle endpoints. Partners represent resellers or managed service providers beneath the distributor. - name: Organizations description: Organization lifecycle and lookup endpoints. - name: Usage description: Usage reporting endpoints for distributor billing and reconciliation. - name: Billing description: Plan assignment and billing-summary endpoints for external invoicing. security: - DistributorApiKey: [] paths: /health: get: tags: - Health summary: Health check operationId: getDistributorHealth security: [] responses: "200": description: Distributor API is healthy. content: application/json: schema: $ref: "#/components/schemas/HealthResponse" /plans: get: tags: - Billing summary: List assignable plans operationId: listDistributorAssignablePlans description: | Returns all SMAO plans available for assignment to customer organizations. Trial and enterprise plans are excluded. Each plan includes its **slug**, **commitment** (monthly/yearly), **baseFee** in the smallest currency unit, and a **usage** array showing included units and overage cost per service. Use the **slug** and **commitment** values when creating or updating an organization's plan. responses: "200": description: Current plans you can assign to distributor-managed organizations. Trial and enterprise plans are excluded. content: application/json: schema: $ref: "#/components/schemas/ListDistributorPlansResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /partners: get: tags: - Partners summary: List partners operationId: listDistributorPartners description: | Returns a paginated list of all partners (MSPs) under your distributor account. Each partner includes an **organizationCount** showing how many customer organizations are linked to it. Use the **externalPartnerId** query parameter to filter by your own partner identifier. parameters: - $ref: "#/components/parameters/Page" - $ref: "#/components/parameters/Limit" - name: externalPartnerId in: query required: false description: Exact-match filter for the distributor partner identifier. schema: type: string minLength: 1 maxLength: 255 responses: "200": description: Partners attributed to this distributor. content: application/json: schema: $ref: "#/components/schemas/ListPartnersResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" post: tags: - Partners summary: Create partner operationId: createDistributorPartner description: | Creates a new partner (MSP) and automatically provisions a SMAO organization for them. **Side effects:** - A new internal SMAO organization is created with the partner's name and billing managed by the distributor. - A free trial is attempted when a trial plan is available; trial setup is best-effort. - If **contactEmail** is provided, invitation creation and email delivery are attempted as best-effort side effects. - The partner's **organizationId** in the response references this auto-created organization. After dashboard access is established, the MSP can create customer organizations from the UI. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreatePartnerRequest" responses: "201": description: Partner created. content: application/json: schema: $ref: "#/components/schemas/GetPartnerResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /partners/{partnerId}: get: tags: - Partners summary: Get partner operationId: getDistributorPartner parameters: - $ref: "#/components/parameters/PartnerId" responses: "200": description: Partner details. content: application/json: schema: $ref: "#/components/schemas/GetPartnerResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" patch: tags: - Partners summary: Update partner operationId: updateDistributorPartner parameters: - $ref: "#/components/parameters/PartnerId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdatePartnerRequest" responses: "200": description: Partner updated. content: application/json: schema: $ref: "#/components/schemas/GetPartnerResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" delete: tags: - Partners summary: Delete partner operationId: deleteDistributorPartner description: | Deletes a partner only when it has no linked customer organizations and no internal MSP organization. Partners created through v1 normally have an internal organization, so this operation returns `409 PARTNER_HAS_ORGANIZATIONS` until that organization has been removed outside this endpoint. There is no cascade delete. parameters: - $ref: "#/components/parameters/PartnerId" responses: "200": description: Partner deleted. content: application/json: schema: $ref: "#/components/schemas/DeletePartnerResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /partners/{partnerId}/plan: patch: tags: - Partners summary: Update assigned plan for the partner's own organization operationId: updateDistributorPartnerPlan description: | Changes the plan assigned to the partner's own (MSP) organization - the one referenced by the partner's **organizationId**. The change takes effect immediately: the current subscription (typically the trial the organization was provisioned with) is marked as upgraded and a new active, distributor-billed subscription is created with the selected plan. If the organization is already on the requested plan, no change is made. This is the only way to move an MSP organization off its trial: it is deliberately not addressable through the customer `/organizations` endpoints. Use the plans endpoint to see available plan slugs and commitment options. parameters: - $ref: "#/components/parameters/PartnerId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateOrganizationPlanRequest" responses: "200": description: Partner organization plan updated. content: application/json: schema: $ref: "#/components/schemas/UpdatePartnerPlanResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /partners/{partnerId}/usage: get: tags: - Usage summary: Get aggregated usage for a single partner's organizations operationId: getDistributorPartnerScopedUsage description: | Returns aggregated usage across all customer organizations that belong to the specified partner (MSP), plus a per-organization breakdown. parameters: - $ref: "#/components/parameters/PartnerId" - $ref: "#/components/parameters/FromDate" - $ref: "#/components/parameters/ToDate" responses: "200": description: Aggregated usage for one partner's organizations. content: application/json: schema: $ref: "#/components/schemas/PartnerUsageResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /organizations: get: tags: - Organizations summary: List organizations operationId: listDistributorOrganizations description: | Returns a paginated list of customer organizations across all your partners. Use the optional **partnerId** query parameter to filter by a specific MSP. Use **externalCustomerId** to look up a customer by your own identifier. parameters: - $ref: "#/components/parameters/Page" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/ExternalCustomerIdFilter" - $ref: "#/components/parameters/PartnerIdFilter" responses: "200": description: Organizations attributed to this partner. content: application/json: schema: $ref: "#/components/schemas/ListOrganizationsResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" post: tags: - Organizations summary: Create organization and initial owner invitation operationId: createDistributorOrganization description: | Creates a new customer organization under the specified partner (MSP), creates its owner invitation record, and attempts to send the invitation email. **What happens:** 1. The organization is created with the selected plan and linked to the partner via **partnerId**. 2. The organization is marked as partner-managed - no Stripe billing is set up for the customer. 3. An invitation record is created for **owner.email**. Email delivery is best-effort. 4. Once the owner accepts, they have full product access but cannot manage billing or subscriptions. The **externalCustomerId** must be unique across all your organizations (not just per partner). It is the idempotency key for this endpoint: retrying a create with the same value returns `DUPLICATE_EXTERNAL_CUSTOMER_ID` rather than creating a second organization. Sending the literal strings `undefined` or `null` is rejected with `VALIDATION_ERROR` - a serialised missing value would otherwise be stored verbatim and disable that protection for every later create. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateOrganizationRequest" responses: "201": description: Organization and owner invitation record created; email delivery may still be pending or fail. content: application/json: schema: $ref: "#/components/schemas/CreateOrganizationResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /organizations/{organizationId}: get: tags: - Organizations summary: Get organization operationId: getDistributorOrganization parameters: - $ref: "#/components/parameters/OrganizationId" responses: "200": description: Organization details. content: application/json: schema: $ref: "#/components/schemas/GetOrganizationResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" patch: tags: - Organizations summary: Update organization operationId: updateDistributorOrganization description: | Update allowed fields on a partner-managed organization. At least one field must be provided. If **externalCustomerId** is changed, uniqueness is enforced within the partner's organizations. parameters: - $ref: "#/components/parameters/OrganizationId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateOrganizationRequest" responses: "200": description: Organization updated. content: application/json: schema: $ref: "#/components/schemas/GetOrganizationResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" delete: tags: - Organizations summary: Delete organization operationId: deleteDistributorOrganization description: | Permanently delete a partner-managed organization and all associated data including subscriptions, invitations, assistants, calls, contacts, knowledge, and S3 storage. This action is irreversible. parameters: - $ref: "#/components/parameters/OrganizationId" responses: "200": description: Organization deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteOrganizationResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /organizations/{organizationId}/plan: patch: tags: - Billing summary: Update assigned plan for an organization operationId: updateDistributorOrganizationPlan description: | Changes the plan assigned to a customer organization. The change takes effect immediately. The previous subscription is marked as upgraded and a new active subscription is created with the selected plan. If the organization is already on the requested plan, no change is made. Use the plans endpoint to see available plan slugs and commitment options. parameters: - $ref: "#/components/parameters/OrganizationId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateOrganizationPlanRequest" responses: "200": description: Organization plan updated. content: application/json: schema: $ref: "#/components/schemas/GetOrganizationResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /organizations/{organizationId}/billing-summary: get: tags: - Billing summary: Get billing summary for a single organization operationId: getDistributorOrganizationBillingSummary description: | Returns usage for the requested date window compared against the organization's current subscription plan. The date range must not exceed 31 days. Usage is compared against the plan's included allowances and any overages are calculated with the plan's per-unit overage cost. parameters: - $ref: "#/components/parameters/OrganizationId" - $ref: "#/components/parameters/FromDate" - $ref: "#/components/parameters/ToDate" responses: "200": description: Billing summary for the requested window. content: application/json: schema: $ref: "#/components/schemas/OrganizationBillingSummaryResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /organizations/{organizationId}/usage: get: tags: - Usage summary: Get usage for a single organization operationId: getDistributorOrganizationUsage parameters: - $ref: "#/components/parameters/OrganizationId" - $ref: "#/components/parameters/FromDate" - $ref: "#/components/parameters/ToDate" responses: "200": description: Usage for one organization in the requested date range. content: application/json: schema: $ref: "#/components/schemas/OrganizationUsageResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" /usage: get: tags: - Usage summary: Get aggregated usage across all organizations operationId: getDistributorPartnerUsage description: | Returns aggregated usage across all customer organizations under this distributor. Optionally filter by **partnerId** to scope results to a single MSP's customers. parameters: - $ref: "#/components/parameters/FromDate" - $ref: "#/components/parameters/ToDate" - $ref: "#/components/parameters/PartnerIdFilter" responses: "200": description: Aggregated usage for all (or filtered) customer organizations. content: application/json: schema: $ref: "#/components/schemas/PartnerUsageResponse" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/RateLimitUnavailable" "500": $ref: "#/components/responses/InternalError" components: securitySchemes: DistributorApiKey: type: http scheme: bearer bearerFormat: API Key description: Long-lived distributor API key scoped to a channel partner on the shared smao instance. parameters: OrganizationId: name: organizationId in: path required: true description: MongoDB ObjectId of the organization. schema: type: string pattern: "^[a-fA-F0-9]{24}$" example: "665abc123def456789012345" FromDate: name: from in: query required: true description: Start date in YYYY-MM-DD format, inclusive. schema: type: string format: date ToDate: name: to in: query required: true description: End date in YYYY-MM-DD format, inclusive. schema: type: string format: date Page: name: page in: query required: false description: 1-based page number. schema: type: integer minimum: 1 default: 1 Limit: name: limit in: query required: false description: Page size. schema: type: integer minimum: 1 maximum: 100 default: 50 ExternalCustomerIdFilter: name: externalCustomerId in: query required: false description: Exact-match filter for the distributor customer identifier. schema: type: string minLength: 1 maxLength: 255 PartnerId: name: partnerId in: path required: true description: MongoDB ObjectId of the partner (MSP). schema: type: string pattern: "^[a-fA-F0-9]{24}$" example: "665abc123def456789012345" PartnerIdFilter: name: partnerId in: query required: false description: Filter results to a specific partner (MSP). Omit to include all organizations. schema: type: string pattern: "^[a-fA-F0-9]{24}$" responses: BadRequest: description: Request validation failed. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" Unauthorized: description: Missing, invalid, or expired API key. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" Forbidden: description: The API key does not have permission to access this resource. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" RateLimited: description: The API key exceeded its configured requests-per-minute limit. headers: X-RateLimit-Limit: description: Maximum requests allowed in the current minute. schema: type: integer example: 60 X-RateLimit-Remaining: description: Requests remaining in the current minute. schema: type: integer example: 0 X-RateLimit-Reset: description: Unix timestamp in seconds when the current window resets. schema: type: integer example: 1776902940 Retry-After: description: Seconds to wait before retrying. schema: type: integer example: 60 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" example: status: error errorCode: RATE_LIMITED message: Too many requests for this API key. RateLimitUnavailable: description: Shared rate-limit storage is unavailable, so the API fails closed. headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer example: 30 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" example: status: error errorCode: RATE_LIMIT_UNAVAILABLE message: Rate limit storage is unavailable. Try again shortly. NotFound: description: The requested resource does not exist or is not in this distributor's scope. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" Conflict: description: A contract conflict occurred, such as a duplicate external ID or a partner that still has organizations. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" InternalError: description: Internal server error. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" schemas: ErrorResponse: type: object additionalProperties: false required: - status - errorCode - message properties: status: type: string enum: - error errorCode: type: string description: Stable machine-readable error code. example: INVALID_DATE_RANGE message: type: string description: Human-readable error message. example: from and to must be valid YYYY-MM-DD values HealthResponse: type: object additionalProperties: false required: - status - service - version - timestamp properties: status: type: string enum: - success service: type: string example: distributor-api version: type: string example: 1.0.0 timestamp: type: string format: date-time OrganizationCreateInput: type: object additionalProperties: false required: - name - timezone properties: name: type: string minLength: 1 maxLength: 255 description: Display name of the organization. example: Acme GmbH industry: type: string maxLength: 255 description: Optional industry label. Can be omitted or left empty. example: IT Services timezone: type: string minLength: 1 maxLength: 100 description: IANA timezone identifier for the organization. example: Europe/Berlin OrganizationOwnerInput: type: object additionalProperties: false required: - email - firstName - lastName properties: email: type: string format: email maxLength: 255 description: Email address used for the required owner invitation record. Email delivery is best-effort. example: owner@example.com firstName: type: string minLength: 1 maxLength: 100 description: First name of the organization owner. example: Alice lastName: type: string minLength: 1 maxLength: 100 description: Last name of the organization owner. example: Müller CreateOrganizationRequest: type: object additionalProperties: false required: - organization - owner - externalCustomerId - partnerId - plan properties: organization: $ref: "#/components/schemas/OrganizationCreateInput" owner: $ref: "#/components/schemas/OrganizationOwnerInput" externalCustomerId: type: string minLength: 1 maxLength: 255 description: >- Your unique customer identifier on the distributor side. This is the idempotency key for organization creation: a retried request carrying the same value is rejected with DUPLICATE_EXTERNAL_CUSTOMER_ID instead of creating a second organization. The literal strings "undefined" and "null" are rejected with VALIDATION_ERROR, because they mean the value was missing on your side and would silently disable that protection. example: SYN-4711 externalContractId: type: string nullable: true maxLength: 255 description: >- Optional contract identifier on the distributor side. The literal strings "undefined" and "null" are rejected with VALIDATION_ERROR; send JSON null or omit the field instead. example: CON-2026-0001 partnerId: type: string pattern: "^[a-fA-F0-9]{24}$" description: ID of the partner (MSP) this organization belongs to. Must reference an active partner. plan: $ref: "#/components/schemas/DistributorPlanSelection" example: organization: name: Acme GmbH timezone: Europe/Berlin owner: email: owner@example.com firstName: Alice lastName: Müller externalCustomerId: SYN-4711 partnerId: 665abc123def456789012345 plan: slug: advanced commitment: monthly UpdateOrganizationRequest: type: object additionalProperties: false minProperties: 1 properties: name: type: string minLength: 1 maxLength: 255 description: Display name of the organization. example: Acme GmbH industry: type: string maxLength: 255 description: Industry label. Send empty string to clear. example: IT Services timezone: type: string minLength: 1 maxLength: 100 description: IANA timezone identifier. example: Europe/Berlin externalCustomerId: type: string minLength: 1 maxLength: 255 description: >- Your unique customer identifier. Must be unique within your partner scope. The literal strings "undefined" and "null" are rejected with VALIDATION_ERROR - they mean the value was missing on your side. example: SYN-4711 externalContractId: type: string nullable: true maxLength: 255 description: >- Optional contract identifier. Send JSON null to clear. The literal strings "undefined" and "null" are rejected with VALIDATION_ERROR. example: CON-2026-0001 example: name: Acme GmbH (renamed) externalContractId: CON-2026-0002 DeleteOrganizationResponse: type: object additionalProperties: false required: - status - message properties: status: type: string enum: - success message: type: string example: Organization deleted. CreatePartnerRequest: type: object additionalProperties: false required: - name - externalPartnerId example: name: "MSP Hamburg GmbH" externalPartnerId: "MSP-001" contactEmail: "admin@msp-hamburg.de" contactName: "Ada Admin" timezone: "Europe/Berlin" properties: name: type: string minLength: 1 maxLength: 255 description: Display name of the partner (MSP). example: MSP Hamburg GmbH externalPartnerId: type: string minLength: 1 maxLength: 255 description: >- Your unique identifier for this partner. Must be unique within your distributor scope, and is the idempotency key for partner creation. The literal strings "undefined" and "null" are rejected with VALIDATION_ERROR, because they mean the value was missing on your side. example: MSP-001 contactEmail: type: string format: email maxLength: 255 description: Primary contact email for this partner. example: admin@msp-hamburg.de contactName: type: string minLength: 1 maxLength: 255 description: Primary contact name. example: Hans Mueller notes: type: string maxLength: 1000 nullable: true description: Internal notes about this partner. timezone: type: string maxLength: 100 description: IANA timezone for the partner's internal MSP organization. Defaults to Europe/Berlin. example: Europe/Berlin UpdatePartnerRequest: type: object additionalProperties: false minProperties: 1 example: contactName: "Grace Admin" properties: name: type: string minLength: 1 maxLength: 255 externalPartnerId: type: string minLength: 1 maxLength: 255 contactEmail: type: string format: email maxLength: 255 nullable: true contactName: type: string minLength: 1 maxLength: 255 nullable: true notes: type: string maxLength: 1000 nullable: true DistributorPartner: type: object additionalProperties: false required: - id - name - externalPartnerId - isActive - createdAt properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" name: type: string externalPartnerId: type: string contactEmail: type: string nullable: true format: email contactName: type: string nullable: true isActive: type: boolean notes: type: string nullable: true organizationId: type: string nullable: true pattern: "^[a-fA-F0-9]{24}$" description: The MSP's own SMAO organization ID. Created automatically when the partner is created. organizationCount: type: integer minimum: 0 description: Number of customer organizations linked to this partner. Present on list and get responses. createdAt: type: string format: date-time example: id: "665abc123def456789012345" name: "MSP Hamburg GmbH" externalPartnerId: "MSP-001" contactEmail: "admin@msp-hamburg.de" contactName: "Hans Mueller" isActive: true notes: null organizationId: "665abc123def456789012346" organizationCount: 12 createdAt: "2026-03-20T10:41:14.553Z" GetPartnerResponse: type: object additionalProperties: false required: - status - partner properties: status: type: string enum: - success partner: $ref: "#/components/schemas/DistributorPartner" UpdatePartnerPlanResponse: type: object additionalProperties: false required: - status - partner - plan properties: status: type: string enum: - success partner: $ref: "#/components/schemas/DistributorPartner" plan: $ref: "#/components/schemas/DistributorPlanSummary" ListPartnersResponse: type: object additionalProperties: false required: - status - page - limit - total - partners properties: status: type: string enum: - success page: type: integer minimum: 1 limit: type: integer minimum: 1 total: type: integer minimum: 0 partners: type: array items: $ref: "#/components/schemas/DistributorPartner" DeletePartnerResponse: type: object additionalProperties: false required: - status - message properties: status: type: string enum: - success message: type: string example: Partner deleted. UpdateOrganizationPlanRequest: type: object additionalProperties: false required: - plan properties: plan: $ref: "#/components/schemas/DistributorPlanSelection" example: plan: slug: professional commitment: yearly DistributorPlanSelection: type: object additionalProperties: false required: - slug - commitment properties: slug: type: string minLength: 1 maxLength: 100 description: Plan slug as returned by the plans endpoint. example: advanced commitment: type: string enum: - none - monthly - yearly description: Commitment variant as returned by the plans endpoint. example: monthly DistributorPlanSummary: type: object additionalProperties: false required: - id - slug - name - commitment - currency - baseFee properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "6967b1c7138bed3e7f6bf7e6" slug: type: string name: type: string commitment: type: string enum: - none - monthly - yearly currency: type: string baseFee: type: integer minimum: 0 description: Reference base fee in the smallest unit of `currency`. You invoice this on your side. DistributorPlanUsageEntry: type: object additionalProperties: false required: - service - included - costPerUnit properties: service: type: string enum: - CALL - FORWARD - SMS - WHATSAPP - ASSISTANT included: type: integer minimum: 0 costPerUnit: type: integer minimum: 0 description: Overage price per unit in the smallest unit of `currency`. DistributorAssignablePlan: type: object additionalProperties: false required: - id - slug - name - commitment - tier - currency - baseFee - usage properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "6967b1c7138bed3e7f6bf7e6" slug: type: string name: type: string commitment: type: string enum: - none - monthly - yearly tier: type: integer minimum: 0 currency: type: string baseFee: type: integer minimum: 0 description: Reference base fee in the smallest unit of `currency`. You invoice this on your side. usage: type: array items: $ref: "#/components/schemas/DistributorPlanUsageEntry" ListDistributorPlansResponse: type: object additionalProperties: false required: - status - count - plans properties: status: type: string enum: - success count: type: integer minimum: 0 plans: type: array items: $ref: "#/components/schemas/DistributorAssignablePlan" DistributorOrganization: type: object additionalProperties: false required: - id - name - industry - timezone - channelPartner - externalCustomerId - externalContractId - partnerId - ownerEmail - createdAt - plan properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "69c175970f3e28310926a1aa" name: type: string industry: type: string timezone: type: string channelPartner: type: string description: Distributor code derived from the API key (e.g. "acme"). example: acme externalCustomerId: type: string externalContractId: type: string nullable: true partnerId: type: string nullable: true pattern: "^[a-fA-F0-9]{24}$" example: "665abc123def456789012345" description: ID of the partner (MSP) this organization belongs to. ownerEmail: type: string nullable: true format: email createdAt: type: string format: date-time plan: type: object nullable: true allOf: - $ref: "#/components/schemas/DistributorPlanSummary" DistributorInvitedUser: type: object additionalProperties: false required: - email - firstName - lastName - role - status - source - invitationId - expiresAt properties: email: type: string format: email firstName: type: string lastName: type: string role: type: string enum: - admin - member status: type: string enum: - invited source: type: string enum: - distributor-api invitationId: type: string pattern: "^[a-fA-F0-9]{24}$" expiresAt: type: string format: date-time CreateOrganizationResponse: type: object additionalProperties: false required: - status - organization - owner properties: status: type: string enum: - success organization: $ref: "#/components/schemas/DistributorOrganization" owner: $ref: "#/components/schemas/DistributorInvitedUser" example: status: success organization: id: "69c175970f3e28310926a1aa" name: "Acme GmbH" industry: "IT Services" timezone: "Europe/Berlin" channelPartner: "acme" externalCustomerId: "SYN-4711" externalContractId: "CON-2026-0001" partnerId: "665abc123def456789012345" ownerEmail: "owner@acme.de" createdAt: "2026-03-23T17:17:11.605Z" plan: id: "6967b1c7138bed3e7f6bf7e6" slug: "advanced" name: "Advanced" commitment: "monthly" currency: "EUR" baseFee: 6900 owner: email: "owner@acme.de" firstName: "Alice" lastName: "Müller" role: "admin" status: "invited" source: "distributor-api" invitationId: "69c175970f3e28310926a1bb" expiresAt: "2026-03-30T17:17:11.814Z" ListOrganizationsResponse: type: object additionalProperties: false required: - status - page - limit - total - organizations properties: status: type: string enum: - success page: type: integer minimum: 1 limit: type: integer minimum: 1 total: type: integer minimum: 0 organizations: type: array items: $ref: "#/components/schemas/DistributorOrganization" GetOrganizationResponse: type: object additionalProperties: false required: - status - organization properties: status: type: string enum: - success organization: $ref: "#/components/schemas/DistributorOrganization" UsagePeriod: type: object additionalProperties: false required: - from - to properties: from: type: string format: date to: type: string format: date UsageDateTimePeriod: type: object additionalProperties: false description: Legacy single-organization usage response period, serialized as UTC timestamps. required: - from - to properties: from: type: string format: date-time to: type: string format: date-time CountMinutesMetric: type: object additionalProperties: false required: - count - minutes properties: count: type: integer minimum: 0 minutes: type: integer minimum: 0 SmsUsageMetric: type: object additionalProperties: false required: - count - segments properties: count: type: integer minimum: 0 segments: type: integer minimum: 0 UsageMetrics: type: object additionalProperties: false required: - calls - forwards - assistants - sms - whatsapp properties: calls: allOf: - $ref: "#/components/schemas/CountMinutesMetric" - description: >- Calls rated as SMAO invoices them. Each call counts its recorded assistant session time, with a minimum of 60 seconds; time stops once the platform hands the call off completely (a completed SIP REFER transfer), while transfers the platform stays on keep counting. A call without recorded session time counts nothing, for example a call rejected by admission policy such as the caller whitelist. Calls fall into the window by their start time, and deleted calls count the usage recorded when they were deleted. `minutes` rounds an organization's total seconds down to whole minutes; aggregate totals add up the per-organization values. forwards: allOf: - $ref: "#/components/schemas/CountMinutesMetric" - description: >- Forwarded calls rated as SMAO invoices them. Each forward counts its recorded forwarding time with a minimum of 60 seconds; once the platform has handed the call off completely (a completed SIP REFER transfer), it counts at most the time from forwarding to that hand-off, without the minimum. A forward without recorded forwarding time counts nothing. Forwards fall into the window by their call's start time, and deleted calls count the usage recorded when they were deleted. `minutes` is rounded like `calls`. assistants: type: integer minimum: 0 description: Assistants created by the end of the requested period that still exist, excluding assistants on a customer-owned (BYON) carrier. sms: $ref: "#/components/schemas/SmsUsageMetric" whatsapp: type: integer minimum: 0 UsageOrganizationRef: type: object additionalProperties: false required: - id - name - externalCustomerId properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "69c175970f3e28310926a1aa" name: type: string externalCustomerId: type: string BillingUsageEntry: type: object additionalProperties: false required: - included - used - overage - costPerUnit - overageCost properties: included: type: integer minimum: 0 description: Units included in the plan's base fee. used: type: integer minimum: 0 description: Units consumed in the requested window. overage: type: integer minimum: 0 description: Units exceeding the included amount (used minus included, minimum zero). costPerUnit: type: integer minimum: 0 description: Overage price per unit in the smallest unit of `currency`. overageCost: type: integer minimum: 0 description: Overage total in the smallest unit of `currency` (overage multiplied by costPerUnit). callCount: type: integer minimum: 0 description: Number of calls (only present for callMinutes and forwardingMinutes). messageCount: type: integer minimum: 0 description: Number of messages (only present for sms and whatsapp). BillingUsage: type: object additionalProperties: false required: - callMinutes - forwardingMinutes - sms - whatsapp - additionalAssistants properties: callMinutes: allOf: - $ref: "#/components/schemas/BillingUsageEntry" - description: Call minutes handled by assistants, rated like `calls` in the usage endpoints. forwardingMinutes: allOf: - $ref: "#/components/schemas/BillingUsageEntry" - description: Call forwarding minutes, rated like `forwards` in the usage endpoints. sms: allOf: - $ref: "#/components/schemas/BillingUsageEntry" - description: SMS segments sent. whatsapp: allOf: - $ref: "#/components/schemas/BillingUsageEntry" - description: WhatsApp messages sent. additionalAssistants: allOf: - $ref: "#/components/schemas/BillingUsageEntry" - description: Additional assistants beyond the plan's included count. BillingSummaryTotals: type: object additionalProperties: false required: - totalOverageCost - currency properties: totalOverageCost: type: integer minimum: 0 description: Sum of all service overage costs in the smallest unit of `currency`. currency: type: string example: EUR OrganizationBillingSummaryResponse: type: object additionalProperties: false required: - status - organization - window - plan - usage - totals properties: status: type: string enum: - success organization: $ref: "#/components/schemas/BillingSummaryOrganizationRef" window: $ref: "#/components/schemas/UsagePeriod" plan: $ref: "#/components/schemas/DistributorPlanSummary" usage: $ref: "#/components/schemas/BillingUsage" totals: $ref: "#/components/schemas/BillingSummaryTotals" example: status: success organization: id: "69c175970f3e28310926a1aa" name: "Acme GmbH" externalCustomerId: "SYN-4711" externalContractId: "CON-2026-0001" window: from: "2026-03-01" to: "2026-03-31" plan: id: "6967b1c7138bed3e7f6bf7e6" slug: "advanced" name: "Advanced" commitment: "monthly" currency: "EUR" baseFee: 6900 usage: callMinutes: included: 500 used: 623 overage: 123 costPerUnit: 15 overageCost: 1845 callCount: 247 forwardingMinutes: included: 100 used: 45 overage: 0 costPerUnit: 25 overageCost: 0 callCount: 18 sms: included: 200 used: 312 overage: 112 costPerUnit: 10 overageCost: 1120 messageCount: 298 whatsapp: included: 100 used: 0 overage: 0 costPerUnit: 5 overageCost: 0 messageCount: 0 additionalAssistants: included: 3 used: 5 overage: 2 costPerUnit: 990 overageCost: 1980 totals: totalOverageCost: 4945 currency: "EUR" BillingSummaryOrganizationRef: type: object additionalProperties: false required: - id - name - externalCustomerId - externalContractId properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "69c175970f3e28310926a1aa" name: type: string externalCustomerId: type: string externalContractId: type: string nullable: true OrganizationUsageResponse: type: object additionalProperties: false required: - status - organization - period - usage properties: status: type: string enum: - success organization: $ref: "#/components/schemas/UsageOrganizationRef" period: $ref: "#/components/schemas/UsageDateTimePeriod" usage: $ref: "#/components/schemas/UsageMetrics" PartnerUsageOrganization: type: object additionalProperties: false required: - id - name - externalCustomerId - usage properties: id: type: string pattern: "^[a-fA-F0-9]{24}$" example: "69c175970f3e28310926a1aa" name: type: string externalCustomerId: type: string usage: $ref: "#/components/schemas/UsageMetrics" PartnerUsageResponse: type: object additionalProperties: false required: - status - period - totals - organizationCount - organizations properties: status: type: string enum: - success period: $ref: "#/components/schemas/UsagePeriod" totals: $ref: "#/components/schemas/UsageMetrics" organizationCount: type: integer minimum: 0 organizations: type: array items: $ref: "#/components/schemas/PartnerUsageOrganization" ````