diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2dea3739..4bb268a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- Added source-build `Client.analytics.metrics` for scoped Prometheus/OpenMetrics gauges, including denominators, observation times and coverage. Uses Analytics opt-in and `sessions:read`.
+
+- `Client.analytics.get` reads WhatsApp number activity, completed 24-hour read and reply cohorts, response times and connection vitals. Requires `sessions:read` and Analytics enabled by a team owner or admin. Missing coverage returns null rates.
+
- Hybrid Link tier transitions. `Client.sessions.quoteTierChange` accepts
`hybridResolution` (`keep` one connection or `split` into two Numbers) for a
Hybrid Link Number leaving Pro, or `hybridMerge` to merge a same-number pair
diff --git a/README.md b/README.md
index ee5a2652..79cf3527 100644
--- a/README.md
+++ b/README.md
@@ -936,3 +936,28 @@ or Number authority. Writes require the exact `expectedRevision`, `prefer`, and
## License
MIT
+
+### WhatsApp analytics
+
+`client.analytics.get({projectId, sessionId, start, end})` reads activity,
+completed 24-hour direct-chat delivery/read/reply cohorts, response times and
+connection vitals. Times are Unix milliseconds and the range is at most 366
+days. A project-bound client confines the request to its own project. Requires
+`sessions:read` and Analytics enabled by a team owner or admin in the Console.
+Disabled results contain no business data. Missing coverage has null rates;
+read receipts do not prove an app open. `measured` distinguishes observed zero
+activity from a number without telemetry. No contact identities or message
+content are returned. See the [Analytics guide](https://docs.polymorfa.com/console/analytics).
+
+### Collector metrics
+
+`client.analytics.metrics({windowHours: 24, format: "openmetrics", segments: true})` returns Prometheus/OpenMetrics text with HTTP metadata, on root and project clients.
+Counts are overlapping-window gauges, with denominators, measurement coverage and observation times.
+Requires `sessions:read` and the same Analytics opt-in. Disabled exports contain only
+enablement and window metadata. [Collector configuration](https://docs.polymorfa.com/console/analytics-collectors).
+
+Analytics responses also expose completed observed phone activity spans and
+quiet gaps, duration sums, sample counts and weighted averages. Ranges up to
+48 hours use hourly activity buckets; longer ranges and call series use daily
+UTC buckets. These signals do not establish phone online/offline or power state.
+See the [measurement definitions](https://docs.polymorfa.com/console/analytics#phone-polymorfa-and-customer-activity).
diff --git a/contracts/README.md b/contracts/README.md
index d1c65da5..8b6e7b66 100644
--- a/contracts/README.md
+++ b/contracts/README.md
@@ -41,23 +41,24 @@ published SDK package before updating their pinned dependency.
## Full snapshots
-The current snapshots are byte-identical copies of Messaging and Platform
-contracts at API implementation commit `ac1c7010d5c3cdf0262f1b5deb2c4d7ce6a249a5` on
-`t3code/project-billing-dashboard`. This branch is unmerged. Seven financial
-operations cover limits, priorities, complete ordering and one-resource combined
-read/save. Project priority takes precedence. Numbers inherit customer priority;
-a higher number priority overrides it within the project. Customer limits still
-include all assigned numbers.
-Initial real QuickLink creation accepts optional billing controls before admission.
-This integrated source inherits five private Console operations: Settings
-Activity, the retained-log calendar, project logging reads, sampling writes and
-HTTPS drain writes. All require dashboard identity and remain excluded from
-server SDK authority. The already-excluded `logsInsight` fingerprint changes
-only for its server-side `filters` and `search` parameters. All public and other
-existing operation fingerprints remain unchanged. The ledger contains 574
-operations: 429 covered and 145 excluded. The financial methods operate actual
-monthly credit-debit limits; the separate nested included-usage/overage quota
-model remains an unshipped prototype outside those methods.
+The current snapshots are byte-identical copies of the Messaging and Platform
+contracts at API dev merge commit `171a9683ff45d11a4b5d3917dc428ad2722b0d99`.
+The full revision contains the inherited Logs and Usage contracts and all four
+public Analytics reads. The ledger contains 579 operations: 433 covered and
+146 excluded, with no missing, partial or changed fingerprints. Six reviewed
+Console changes cover analytics projections, consent preferences, operational
+error vitals and shared drains. They require dashboard identity and remain
+excluded from server SDK authority.
+
+The seven financial operations cover limits, priorities, complete ordering and
+one-resource combined read/save. Project priority takes precedence. Numbers
+inherit customer priority; a higher number priority overrides it within the
+project. Customer limits include all assigned numbers. Initial real QuickLink
+creation accepts optional billing controls before admission. These methods
+operate actual monthly credit-debit limits; the separate nested included-usage
+quota prototype remains outside those methods. Reads require `billing:read`;
+writes require `billing:manage`, live team authority and Pay-As-You-Go.
+SDK publication and API deployment remain separate gates.
The preceding repin at `b8fa167e47c901131d3bd9865e6479b153ad5157`
added only the private Console `listConsoleNumberOptions` operation,
@@ -286,6 +287,9 @@ exported `ContactOptPayload`. The unproduced `bansafe.health_changed`,
`bansafe.risk_changed`, and `bansafe.enforcement` names are retired. Historical
signed events with these names still decode as unknown events.
+The base contract snapshot below excludes the four WhatsApp business analytics
+reads described at the end of this document.
+
| Status | Operations |
| ------------------- | ---------: |
| Covered | 422 |
@@ -467,3 +471,17 @@ spec while the beta has no audience. The full snapshots stay pinned to
coverage ledger as `Client.campaigns.recordConversion` and
`Client.campaigns.conversions`. `campaign-conversions-contract.test.ts`
compares the exported types with the supplement.
+
+## WhatsApp business analytics contract
+
+`Client.analytics.get` covers the team and project Analytics reads.
+The strict response contains number/project-scoped messaging aggregates,
+retained-call outcomes, measured timings, quality, failure categories,
+missed-call follow-up and daily call series. `Client.analytics.metrics` covers
+the two collector reads and returns Prometheus/OpenMetrics text rather than
+a JSON envelope. All four use scoped reader authority and Analytics opt-in.
+
+The full Platform snapshot, each Analytics operation and `source.json` pin the
+same API source revision. Reconciliation tests check exact snapshot hashes and
+strict operation fingerprints. Counts do not establish package publication or
+customer access.
diff --git a/contracts/coverage.json b/contracts/coverage.json
index 49cd9221..b758bae2 100644
--- a/contracts/coverage.json
+++ b/contracts/coverage.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
- "sourceCommit": "ac1c7010d5c3cdf0262f1b5deb2c4d7ce6a249a5",
+ "sourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
"excludedContracts": [
{
"family": "graph",
@@ -2956,7 +2956,7 @@
"method": "GET",
"path": "/console/insights/analytics",
"operationId": "analyticsInsight",
- "fingerprint": "6574426025ecc2f85e5cb33c616ea91170789fb3834f7dbc4a609358df4e79a6",
+ "fingerprint": "09f2d7ba74398a51e05102ab22bb7e137f6e6019825574615b9a095a9005976c",
"typescript": {
"status": "excluded",
"reason": "Console-only operation requires dashboard identity; not available to SDK credentials."
@@ -3000,7 +3000,7 @@
"method": "GET",
"path": "/console/insights/preferences",
"operationId": "getInsightPreferences",
- "fingerprint": "7bfc1895f88c744415f0e1db6915e7afcad8414c1debeaf0ff5e154f2aa7e01e",
+ "fingerprint": "6e65a8a39cf4e9952e71073ee9c0446be4a3e02ba3d70a67471092f8c2be0851",
"typescript": {
"status": "excluded",
"reason": "Console-only operation requires dashboard identity; not available to SDK credentials."
@@ -3088,7 +3088,7 @@
"method": "GET",
"path": "/console/projects/{projectId}/logging",
"operationId": "getProjectLogging",
- "fingerprint": "ee09b8d569d9b33bd5e65c1ead0bb2b71ee4b8e8e766627bb97a99e24f78fc19",
+ "fingerprint": "00ccc14321453e8653c1cde355a42bdea78d7d3bace5b7a95bbc13d4f82d5b3a",
"typescript": {
"status": "excluded",
"reason": "Console-only operation requires dashboard identity; organization API keys and project tokens are not accepted."
@@ -6033,7 +6033,7 @@
"method": "PUT",
"path": "/console/insights/preferences",
"operationId": "updateInsightPreferences",
- "fingerprint": "987095e182ff41ca660e7531f0343ad0e22a5c59f39ec730cd56a7feb7e78565",
+ "fingerprint": "596ecaa4a34cc9c991aa33aabadf76670f970291a15f721bb8567076c3fb6048",
"typescript": {
"status": "excluded",
"reason": "Console-only operation requires dashboard identity; not available to SDK credentials."
@@ -6077,7 +6077,7 @@
"method": "PUT",
"path": "/console/projects/{projectId}/logging/drains",
"operationId": "saveProjectDrain",
- "fingerprint": "d6464828734345de6640155503d469531741f527bcb1bcd24d28ecdc15d39aae",
+ "fingerprint": "d8de37aca07f8b8b24c4ed5444896dfd7458c3b248a010750ac445a096f28f33",
"typescript": {
"status": "excluded",
"reason": "Console-only operation requires dashboard identity; organization API keys and project tokens are not accepted."
@@ -6335,6 +6335,73 @@
"status": "covered",
"method": "Client.sessions.updateSafeMode"
}
+ },
+ {
+ "family": "platform",
+ "method": "GET",
+ "path": "/platform/analytics",
+ "operationId": "getWhatsAppAnalytics",
+ "fingerprint": "1ac8b2438ef13263289d71f0d956fd3f7b60b44ac0999151bc96f30d31798b91",
+ "typescript": {
+ "status": "covered",
+ "method": "Client.analytics.get"
+ },
+ "sourceContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
+ "sdkContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8"
+ },
+ {
+ "family": "platform",
+ "method": "GET",
+ "path": "/platform/projects/{projectId}/analytics",
+ "operationId": "getProjectWhatsAppAnalytics",
+ "fingerprint": "dc6ab3ff0e56d2e97c26fb64c2090b8b8f900a0f09c86e221f84932e9fb0a36f",
+ "typescript": {
+ "status": "covered",
+ "method": "Client.analytics.get"
+ },
+ "sourceContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
+ "sdkContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8"
+ },
+ {
+ "family": "platform",
+ "method": "GET",
+ "path": "/platform/analytics/metrics",
+ "operationId": "exportWhatsAppAnalyticsMetrics",
+ "typescript": {
+ "status": "covered",
+ "method": "Client.analytics.metrics"
+ },
+ "fingerprint": "520ce37bc6a198e313d9495ddd968ebb54cbbe5cc84f4d6bea636c43a4ea62c5",
+ "sourceContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sdkContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99"
+ },
+ {
+ "family": "platform",
+ "method": "GET",
+ "path": "/platform/projects/{projectId}/analytics/metrics",
+ "operationId": "exportProjectWhatsAppAnalyticsMetrics",
+ "typescript": {
+ "status": "covered",
+ "method": "Client.analytics.metrics"
+ },
+ "fingerprint": "56e2540bc5d78c3f458fbd363d63b101df49fcd273b7cc81e1fa9318d69b30b6",
+ "sourceContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sdkContractSha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ "sourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99"
+ },
+ {
+ "family": "platform",
+ "method": "GET",
+ "path": "/console/insights/vitals",
+ "operationId": "getConsoleErrorVitals",
+ "fingerprint": "4c3c1667cc5f2feb631064100976c3e399cd917cb29e4f336b85baf81335e217",
+ "typescript": {
+ "status": "excluded",
+ "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority."
+ }
}
]
}
diff --git a/contracts/openapi.platform.json b/contracts/openapi.platform.json
index a6b6ba4a..fcf46624 100644
--- a/contracts/openapi.platform.json
+++ b/contracts/openapi.platform.json
@@ -3,7 +3,7 @@
"info": {
"title": "Polymorfa Console & Management API",
"version": "1.0.0",
- "description": "Complete console/management surface. Resource-oriented REST: `{param}` path variables, single-word action sub-resources.\n\nNamespaces decide inclusion:\n- `/platform/…` — **public registry** (243 endpoints): each endpoint declares either a scoped `pmfa_` organization key or a verified live dashboard JWT, or dashboard-only authorization when a specific user's authority is required. Only implemented handlers are published to customers and SDKs.\n- `/console/…` — **console-only** (110 endpoints): verified dashboard identity. EXCLUDED from the published spec; reason per endpoint.\n\nThe published customer spec = this document filtered to `x-audience: public` endpoints with an implemented runtime handler.\nMost transitional body/response schemas remain loose (`additionalProperties: true`); session-control operations use explicit strict schemas."
+ "description": "Complete console/management surface. Resource-oriented REST: `{param}` path variables, single-word action sub-resources.\n\nNamespaces decide inclusion:\n- `/platform/…` — **public registry** (247 endpoints): each endpoint declares either a scoped `pmfa_` organization key or a verified live dashboard JWT, or dashboard-only authorization when a specific user's authority is required. Only implemented handlers are published to customers and SDKs.\n- `/console/…` — **console-only** (111 endpoints): verified dashboard identity. EXCLUDED from the published spec; reason per endpoint.\n\nThe published customer spec = this document filtered to `x-audience: public` endpoints with an implemented runtime handler.\nMost transitional body/response schemas remain loose (`additionalProperties: true`); session-control operations use explicit strict schemas."
},
"servers": [
{
@@ -3041,27 +3041,98 @@
"x-audience": "public"
}
},
- "/platform/sip/endpoint": {
+ "/platform/analytics/metrics": {
"get": {
- "operationId": "getSipEndpoint",
+ "operationId": "exportWhatsAppAnalyticsMetrics",
"tags": [
- "SIP Trunks"
+ "Analytics"
],
- "summary": "Get the SIP address",
- "description": "**Required scope:** `sessions:read`\n\nReturns the host and ports your PBX sends SIP to, and the RTP port range to allow for call audio. When this environment does not host SIP, `status` is `sip_not_hosted` and no address is returned. The address is the same for every trunk of every project.",
+ "summary": "Export analytics metrics",
+ "description": "**Required scope:** `sessions:read`\n\nAuthenticated metrics for a customer's Prometheus or OpenTelemetry Collector. Requires live Analytics enablement. Disabled Analytics returns only enablement and export-window metrics, with no business measurements. All samples are gauges: counts describe an overlapping window, not monotonic counters. Do not use rate() or increase() on them. Labels contain number/project record IDs and bounded categories, never contact identities or message content. Null or unmeasured values are omitted; coverage and observation timestamps are included. API request vitals retain project/organization scope even when filtering a number.",
"security": [
{
"ApiKeyAuth": []
}
],
+ "x-polymorfa-resource-boundary": "project",
"x-required-scope": "sessions:read",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": false,
+ "name": "projectId",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "description": "One number record UUID, within the authorized project or organization."
+ },
+ "required": false,
+ "description": "One number record UUID, within the authorized project or organization.",
+ "name": "sessionId",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 168,
+ "default": 24,
+ "description": "Number of completed UTC hours in the window, ending at the most recent hour boundary. Default 24; maximum 168."
+ },
+ "required": false,
+ "description": "Number of completed UTC hours in the window, ending at the most recent hour boundary. Default 24; maximum 168.",
+ "name": "windowHours",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "true",
+ "false"
+ ],
+ "default": "false",
+ "description": "Include message type, text length, origin, calling-code and device-count breakdowns. Adds metric series."
+ },
+ "required": false,
+ "description": "Include message type, text length, origin, calling-code and device-count breakdowns. Adds metric series.",
+ "name": "segments",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "prometheus",
+ "openmetrics"
+ ],
+ "default": "prometheus",
+ "description": "Prometheus text 0.0.4 or OpenMetrics text 1.0. The format parameter selects the representation."
+ },
+ "required": false,
+ "description": "Prometheus text 0.0.4 or OpenMetrics text 1.0. The format parameter selects the representation.",
+ "name": "format",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
- "description": "The SIP address, or `sip_not_hosted`.",
+ "description": "Windowed analytics gauges. OpenMetrics ends with # EOF. No JSON envelope.",
"content": {
- "application/json": {
+ "text/plain": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessGetSipEndpointResponse"
+ "type": "string"
+ }
+ },
+ "application/openmetrics-text": {
+ "schema": {
+ "type": "string"
}
}
},
@@ -3236,6 +3307,38 @@
"$ref": "#/components/headers/X-RateLimit-Reset"
}
}
+ },
+ "503": {
+ "description": "Analytics query deadline exceeded. Retry later or use a shorter window.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "service_unavailable",
+ "message": "A required service is unavailable. Check completion before repeating a write.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#service-unavailable"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
}
},
"x-mint": {
@@ -3244,39 +3347,98 @@
"x-audience": "public"
}
},
- "/platform/sip-trunks": {
+ "/platform/projects/{projectId}/analytics/metrics": {
"get": {
- "operationId": "listSipTrunks",
+ "operationId": "exportProjectWhatsAppAnalyticsMetrics",
"tags": [
- "SIP Trunks"
+ "Analytics"
],
- "summary": "List SIP trunks",
- "description": "**Required scope:** `sessions:read`\n\nLists the SIP trunks of a project. Passwords are never returned.",
+ "summary": "Export project analytics metrics",
+ "description": "**Required scope:** `sessions:read`\n\nAuthenticated metrics for a customer's Prometheus or OpenTelemetry Collector. Requires live Analytics enablement. Disabled Analytics returns only enablement and export-window metrics, with no business measurements. All samples are gauges: counts describe an overlapping window, not monotonic counters. Do not use rate() or increase() on them. Labels contain number/project record IDs and bounded categories, never contact identities or message content. Null or unmeasured values are omitted; coverage and observation timestamps are included. API request vitals retain project/organization scope even when filtering a number.",
"security": [
{
"ApiKeyAuth": []
}
],
+ "x-polymorfa-resource-boundary": "project",
"x-required-scope": "sessions:read",
"parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": true,
+ "name": "projectId",
+ "in": "path"
+ },
{
"schema": {
"type": "string",
"format": "uuid",
- "description": "Project whose trunks are listed. Required for organization keys."
+ "description": "One number record UUID, within the authorized project or organization."
},
"required": false,
- "name": "projectId",
+ "description": "One number record UUID, within the authorized project or organization.",
+ "name": "sessionId",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 168,
+ "default": 24,
+ "description": "Number of completed UTC hours in the window, ending at the most recent hour boundary. Default 24; maximum 168."
+ },
+ "required": false,
+ "description": "Number of completed UTC hours in the window, ending at the most recent hour boundary. Default 24; maximum 168.",
+ "name": "windowHours",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "true",
+ "false"
+ ],
+ "default": "false",
+ "description": "Include message type, text length, origin, calling-code and device-count breakdowns. Adds metric series."
+ },
+ "required": false,
+ "description": "Include message type, text length, origin, calling-code and device-count breakdowns. Adds metric series.",
+ "name": "segments",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "prometheus",
+ "openmetrics"
+ ],
+ "default": "prometheus",
+ "description": "Prometheus text 0.0.4 or OpenMetrics text 1.0. The format parameter selects the representation."
+ },
+ "required": false,
+ "description": "Prometheus text 0.0.4 or OpenMetrics text 1.0. The format parameter selects the representation.",
+ "name": "format",
"in": "query"
}
],
"responses": {
"200": {
- "description": "SIP trunks in creation order.",
+ "description": "Windowed analytics gauges. OpenMetrics ends with # EOF. No JSON envelope.",
"content": {
- "application/json": {
+ "text/plain": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessListSipTrunksResponse"
+ "type": "string"
+ }
+ },
+ "application/openmetrics-text": {
+ "schema": {
+ "type": "string"
}
}
},
@@ -3451,43 +3613,67 @@
"$ref": "#/components/headers/X-RateLimit-Reset"
}
}
+ },
+ "503": {
+ "description": "Analytics query deadline exceeded. Retry later or use a shorter window.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "service_unavailable",
+ "message": "A required service is unavailable. Check completion before repeating a write.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#service-unavailable"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
}
},
"x-mint": {
"content": "sessions:read"
},
"x-audience": "public"
- },
- "post": {
- "operationId": "createSipTrunk",
+ }
+ },
+ "/platform/sip/endpoint": {
+ "get": {
+ "operationId": "getSipEndpoint",
"tags": [
"SIP Trunks"
],
- "summary": "Create a SIP trunk",
- "description": "**Required scope:** `sessions:manage`\n\nConnects a PBX or SIP trunk to WhatsApp calls. An `outbound` trunk receives the incoming WhatsApp calls of sessions whose call settings route to it. An `inbound` trunk lets your PBX place WhatsApp calls through a session. For trunks that accept calls from your PBX, the response contains the digest credentials once.",
+ "summary": "Get the SIP address",
+ "description": "**Required scope:** `sessions:read`\n\nReturns the host and ports your PBX sends SIP to, and the RTP port range to allow for call audio. When this environment does not host SIP, `status` is `sip_not_hosted` and no address is returned. The address is the same for every trunk of every project.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "sessions:manage",
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateSipTrunkRequest"
- }
- }
- }
- },
+ "x-required-scope": "sessions:read",
"responses": {
- "201": {
- "description": "SIP trunk created. Store `inboundCredentials.password` now; it cannot be retrieved later.",
+ "200": {
+ "description": "The SIP address, or `sip_not_hosted`.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateSipTrunkResponse"
+ "$ref": "#/components/schemas/PlatformAccessGetSipEndpointResponse"
}
}
},
@@ -3631,38 +3817,6 @@
}
}
},
- "409": {
- "description": "`sip_trunk_limit`: the project already has 100 trunks.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "conflict_error",
- "code": "sip_trunk_limit",
- "message": "The project has reached its SIP trunk limit.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-limit"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
"500": {
"description": "Internal server error",
"content": {
@@ -3694,53 +3848,22 @@
"$ref": "#/components/headers/X-RateLimit-Reset"
}
}
- },
- "503": {
- "description": "`sip_unavailable`: SIP trunks are not available right now.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "api_error",
- "code": "sip_unavailable",
- "message": "SIP trunks are not available.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#sip-unavailable"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
}
},
"x-mint": {
- "content": "sessions:manage"
+ "content": "sessions:read"
},
"x-audience": "public"
}
},
- "/platform/sip-trunks/{trunkId}": {
+ "/platform/sip-trunks": {
"get": {
- "operationId": "getSipTrunk",
+ "operationId": "listSipTrunks",
"tags": [
"SIP Trunks"
],
- "summary": "Get a SIP trunk",
+ "summary": "List SIP trunks",
+ "description": "**Required scope:** `sessions:read`\n\nLists the SIP trunks of a project. Passwords are never returned.",
"security": [
{
"ApiKeyAuth": []
@@ -3751,20 +3874,21 @@
{
"schema": {
"type": "string",
- "format": "uuid"
+ "format": "uuid",
+ "description": "Project whose trunks are listed. Required for organization keys."
},
- "required": true,
- "name": "trunkId",
- "in": "path"
+ "required": false,
+ "name": "projectId",
+ "in": "query"
}
],
"responses": {
"200": {
- "description": "The SIP trunk. Passwords are never returned.",
+ "description": "SIP trunks in creation order.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessGetSipTrunkResponse"
+ "$ref": "#/components/schemas/PlatformAccessListSipTrunksResponse"
}
}
},
@@ -3944,50 +4068,38 @@
"x-mint": {
"content": "sessions:read"
},
- "description": "**Required scope:** `sessions:read`",
"x-audience": "public"
},
- "patch": {
- "operationId": "updateSipTrunk",
+ "post": {
+ "operationId": "createSipTrunk",
"tags": [
"SIP Trunks"
],
- "summary": "Update a SIP trunk",
- "description": "**Required scope:** `sessions:manage`\n\nChanges the fields you send and keeps the others. Send `expectedRevision` to refuse the change with `409 sip_trunk_revision_conflict` when the trunk changed since you read it. Changes apply to new calls within about a minute; calls in progress continue.",
+ "summary": "Create a SIP trunk",
+ "description": "**Required scope:** `sessions:manage`\n\nConnects a PBX or SIP trunk to WhatsApp calls. An `outbound` trunk receives the incoming WhatsApp calls of sessions whose call settings route to it. An `inbound` trunk lets your PBX place WhatsApp calls through a session. For trunks that accept calls from your PBX, the response contains the digest credentials once.",
"security": [
{
"ApiKeyAuth": []
}
],
"x-required-scope": "sessions:manage",
- "parameters": [
- {
- "schema": {
- "type": "string",
- "format": "uuid"
- },
- "required": true,
- "name": "trunkId",
- "in": "path"
- }
- ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateSipTrunkRequest"
+ "$ref": "#/components/schemas/PlatformAccessCreateSipTrunkRequest"
}
}
}
},
"responses": {
- "200": {
- "description": "The updated SIP trunk.",
+ "201": {
+ "description": "SIP trunk created. Store `inboundCredentials.password` now; it cannot be retrieved later.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateSipTrunkResponse"
+ "$ref": "#/components/schemas/PlatformAccessCreateSipTrunkResponse"
}
}
},
@@ -4132,7 +4244,7 @@
}
},
"409": {
- "description": "`sip_trunk_revision_conflict`: the trunk changed after `expectedRevision`.",
+ "description": "`sip_trunk_limit`: the project already has 100 trunks.",
"content": {
"application/json": {
"schema": {
@@ -4141,13 +4253,13 @@
"example": {
"error": {
"type": "conflict_error",
- "code": "sip_trunk_revision_conflict",
- "message": "The SIP trunk changed since it was read.",
+ "code": "sip_trunk_limit",
+ "message": "The project has reached its SIP trunk limit.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-revision-conflict"
+ "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-limit"
}
}
},
@@ -4232,20 +4344,21 @@
"content": "sessions:manage"
},
"x-audience": "public"
- },
- "delete": {
- "operationId": "deleteSipTrunk",
+ }
+ },
+ "/platform/sip-trunks/{trunkId}": {
+ "get": {
+ "operationId": "getSipTrunk",
"tags": [
"SIP Trunks"
],
- "summary": "Delete a SIP trunk",
- "description": "**Required scope:** `sessions:manage`\n\nDeletes the trunk and its credentials. Returns `409 sip_trunk_in_use` while a session's call settings route incoming calls to it.",
+ "summary": "Get a SIP trunk",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "sessions:manage",
+ "x-required-scope": "sessions:read",
"parameters": [
{
"schema": {
@@ -4259,11 +4372,11 @@
],
"responses": {
"200": {
- "description": "SIP trunk deleted.",
+ "description": "The SIP trunk. Passwords are never returned.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessDeleteSipTrunkResponse"
+ "$ref": "#/components/schemas/PlatformAccessGetSipTrunkResponse"
}
}
},
@@ -4407,38 +4520,6 @@
}
}
},
- "409": {
- "description": "`sip_trunk_in_use`: a session's call settings route incoming calls to the trunk.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "conflict_error",
- "code": "sip_trunk_in_use",
- "message": "A session still sends incoming calls to this SIP trunk.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-in-use"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
"500": {
"description": "Internal server error",
"content": {
@@ -4473,19 +4554,18 @@
}
},
"x-mint": {
- "content": "sessions:manage"
+ "content": "sessions:read"
},
+ "description": "**Required scope:** `sessions:read`",
"x-audience": "public"
- }
- },
- "/platform/sip-trunks/{trunkId}/credentials": {
- "post": {
- "operationId": "rotateSipTrunkCredentials",
+ },
+ "patch": {
+ "operationId": "updateSipTrunk",
"tags": [
"SIP Trunks"
],
- "summary": "Rotate SIP trunk credentials",
- "description": "**Required scope:** `sessions:manage`\n\nReplaces the password your PBX uses to place calls. The previous password stops working within about a minute. Returns `409 state_conflict` for `outbound` trunks.",
+ "summary": "Update a SIP trunk",
+ "description": "**Required scope:** `sessions:manage`\n\nChanges the fields you send and keeps the others. Send `expectedRevision` to refuse the change with `409 sip_trunk_revision_conflict` when the trunk changed since you read it. Changes apply to new calls within about a minute; calls in progress continue.",
"security": [
{
"ApiKeyAuth": []
@@ -4503,13 +4583,23 @@
"in": "path"
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessUpdateSipTrunkRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
- "description": "New credentials. The password is shown only in this response.",
+ "description": "The updated SIP trunk.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessRotateSipTrunkCredentialsResponse"
+ "$ref": "#/components/schemas/PlatformAccessUpdateSipTrunkResponse"
}
}
},
@@ -4654,7 +4744,7 @@
}
},
"409": {
- "description": "`state_conflict`: the trunk does not accept calls from a PBX.",
+ "description": "`sip_trunk_revision_conflict`: the trunk changed after `expectedRevision`.",
"content": {
"application/json": {
"schema": {
@@ -4663,13 +4753,13 @@
"example": {
"error": {
"type": "conflict_error",
- "code": "state_conflict",
- "message": "The request conflicts with the resource state.",
+ "code": "sip_trunk_revision_conflict",
+ "message": "The SIP trunk changed since it was read.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#state-conflict"
+ "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-revision-conflict"
}
}
},
@@ -4754,29 +4844,38 @@
"content": "sessions:manage"
},
"x-audience": "public"
- }
- },
- "/platform/call-policy": {
- "get": {
- "operationId": "getCallPolicy",
+ },
+ "delete": {
+ "operationId": "deleteSipTrunk",
"tags": [
- "Call Consent"
+ "SIP Trunks"
],
- "summary": "Get the team's call policy",
- "description": "**Required scope:** `sessions:read`\n\nReturns the team's blocked country codes and the size of its do-not-call list.",
+ "summary": "Delete a SIP trunk",
+ "description": "**Required scope:** `sessions:manage`\n\nDeletes the trunk and its credentials. Returns `409 sip_trunk_in_use` while a session's call settings route incoming calls to it.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "sessions:read",
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": true,
+ "name": "trunkId",
+ "in": "path"
+ }
+ ],
"responses": {
"200": {
- "description": "The call policy.",
+ "description": "SIP trunk deleted.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessGetCallPolicyResponse"
+ "$ref": "#/components/schemas/PlatformAccessDeleteSipTrunkResponse"
}
}
},
@@ -4920,6 +5019,38 @@
}
}
},
+ "409": {
+ "description": "`sip_trunk_in_use`: a session's call settings route incoming calls to the trunk.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "conflict_error",
+ "code": "sip_trunk_in_use",
+ "message": "A session still sends incoming calls to this SIP trunk.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#sip-trunk-in-use"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"content": {
@@ -4954,40 +5085,43 @@
}
},
"x-mint": {
- "content": "sessions:read"
+ "content": "sessions:manage"
},
"x-audience": "public"
- },
- "put": {
- "operationId": "updateCallPolicy",
+ }
+ },
+ "/platform/sip-trunks/{trunkId}/credentials": {
+ "post": {
+ "operationId": "rotateSipTrunkCredentials",
"tags": [
- "Call Consent"
+ "SIP Trunks"
],
- "summary": "Update the team's blocked country codes",
- "description": "**Required scope:** `sessions:manage`\n\nReplaces the blocked country codes. New calls are checked against the new list at once; calls in progress continue.",
+ "summary": "Rotate SIP trunk credentials",
+ "description": "**Required scope:** `sessions:manage`\n\nReplaces the password your PBX uses to place calls. The previous password stops working within about a minute. Returns `409 state_conflict` for `outbound` trunks.",
"security": [
{
"ApiKeyAuth": []
}
],
"x-required-scope": "sessions:manage",
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateCallPolicyRequest"
- }
- }
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": true,
+ "name": "trunkId",
+ "in": "path"
}
- },
+ ],
"responses": {
"200": {
- "description": "The updated call policy.",
+ "description": "New credentials. The password is shown only in this response.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateCallPolicyResponse"
+ "$ref": "#/components/schemas/PlatformAccessRotateSipTrunkCredentialsResponse"
}
}
},
@@ -5132,7 +5266,7 @@
}
},
"409": {
- "description": "`state_conflict`: the policy changed after `expectedRevision`.",
+ "description": "`state_conflict`: the trunk does not accept calls from a PBX.",
"content": {
"application/json": {
"schema": {
@@ -5194,6 +5328,38 @@
"$ref": "#/components/headers/X-RateLimit-Reset"
}
}
+ },
+ "503": {
+ "description": "`sip_unavailable`: SIP trunks are not available right now.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "sip_unavailable",
+ "message": "SIP trunks are not available.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#sip-unavailable"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
}
},
"x-mint": {
@@ -5202,71 +5368,27 @@
"x-audience": "public"
}
},
- "/platform/call-opt-outs": {
+ "/platform/call-policy": {
"get": {
- "operationId": "listCallOptOuts",
+ "operationId": "getCallPolicy",
"tags": [
"Call Consent"
],
- "summary": "List the do-not-call list",
- "description": "**Required scope:** `sessions:read`\n\nLists the team's do-not-call entries, newest first.",
+ "summary": "Get the team's call policy",
+ "description": "**Required scope:** `sessions:read`\n\nReturns the team's blocked country codes and the size of its do-not-call list.",
"security": [
{
"ApiKeyAuth": []
}
],
"x-required-scope": "sessions:read",
- "parameters": [
- {
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 500,
- "default": 100,
- "description": "Entries per page, 1 to 500."
- },
- "required": false,
- "name": "limit",
- "in": "query"
- },
- {
- "schema": {
- "type": "string",
- "format": "uuid",
- "description": "`page.nextCursor` from the previous page."
- },
- "required": false,
- "name": "cursor",
- "in": "query"
- },
- {
- "schema": {
- "type": "string",
- "pattern": "^\\+[1-9]\\d{1,14}$",
- "description": "Return only the entry for this phone number."
- },
- "required": false,
- "name": "phoneNumber",
- "in": "query"
- },
- {
- "schema": {
- "type": "string",
- "pattern": "^[A-Z]{2}\\.[A-Za-z0-9]{1,128}$",
- "description": "Return only the entry for this BSUID."
- },
- "required": false,
- "name": "bsuid",
- "in": "query"
- }
- ],
"responses": {
"200": {
- "description": "One page of entries.",
+ "description": "The call policy.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessListCallOptOutsResponse"
+ "$ref": "#/components/schemas/PlatformAccessGetCallPolicyResponse"
}
}
},
@@ -5448,13 +5570,13 @@
},
"x-audience": "public"
},
- "post": {
- "operationId": "createCallOptOut",
+ "put": {
+ "operationId": "updateCallPolicy",
"tags": [
"Call Consent"
],
- "summary": "Add a person to the do-not-call list",
- "description": "**Required scope:** `sessions:manage`\n\nAdds a phone number or BSUID. Calls to it are refused with `403 call_recipient_opted_out` from then on. Adding an entry that already exists returns it with `200`.",
+ "summary": "Update the team's blocked country codes",
+ "description": "**Required scope:** `sessions:manage`\n\nReplaces the blocked country codes. New calls are checked against the new list at once; calls in progress continue.",
"security": [
{
"ApiKeyAuth": []
@@ -5466,39 +5588,18 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateCallOptOutRequest"
+ "$ref": "#/components/schemas/PlatformAccessUpdateCallPolicyRequest"
}
}
}
},
"responses": {
"200": {
- "description": "The entry already existed.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessExistingCallOptOutResponse"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
- "201": {
- "description": "Entry added.",
+ "description": "The updated call policy.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateCallOptOutResponse"
+ "$ref": "#/components/schemas/PlatformAccessUpdateCallPolicyResponse"
}
}
},
@@ -5643,7 +5744,7 @@
}
},
"409": {
- "description": "`call_opt_out_limit`: the list already holds 100,000 entries.",
+ "description": "`state_conflict`: the policy changed after `expectedRevision`.",
"content": {
"application/json": {
"schema": {
@@ -5652,13 +5753,13 @@
"example": {
"error": {
"type": "conflict_error",
- "code": "call_opt_out_limit",
- "message": "The team's do-not-call list is full.",
+ "code": "state_conflict",
+ "message": "The request conflicts with the resource state.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#call-opt-out-limit"
+ "docs": "https://docs.polymorfa.com/api/errors#state-conflict"
}
}
},
@@ -5713,37 +5814,71 @@
"x-audience": "public"
}
},
- "/platform/call-opt-outs/import": {
- "post": {
- "operationId": "importCallOptOuts",
+ "/platform/call-opt-outs": {
+ "get": {
+ "operationId": "listCallOptOuts",
"tags": [
"Call Consent"
],
- "summary": "Import do-not-call entries",
- "description": "**Required scope:** `sessions:manage`\n\nAdds up to 5,000 entries in one request. Valid entries are added together; invalid ones are listed in `rejected`. If the valid entries would take the list past its limit, nothing is added and the request fails with `409 call_opt_out_limit`.",
+ "summary": "List the do-not-call list",
+ "description": "**Required scope:** `sessions:read`\n\nLists the team's do-not-call entries, newest first.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "sessions:manage",
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessImportCallOptOutsRequest"
- }
- }
+ "x-required-scope": "sessions:read",
+ "parameters": [
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 500,
+ "default": 100,
+ "description": "Entries per page, 1 to 500."
+ },
+ "required": false,
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "description": "`page.nextCursor` from the previous page."
+ },
+ "required": false,
+ "name": "cursor",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "pattern": "^\\+[1-9]\\d{1,14}$",
+ "description": "Return only the entry for this phone number."
+ },
+ "required": false,
+ "name": "phoneNumber",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "pattern": "^[A-Z]{2}\\.[A-Za-z0-9]{1,128}$",
+ "description": "Return only the entry for this BSUID."
+ },
+ "required": false,
+ "name": "bsuid",
+ "in": "query"
}
- },
+ ],
"responses": {
"200": {
- "description": "Import result.",
+ "description": "One page of entries.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessImportCallOptOutsResponse"
+ "$ref": "#/components/schemas/PlatformAccessListCallOptOutsResponse"
}
}
},
@@ -5887,38 +6022,6 @@
}
}
},
- "409": {
- "description": "`call_opt_out_limit`: the entries would take the list past 100,000 entries.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "conflict_error",
- "code": "call_opt_out_limit",
- "message": "The team's do-not-call list is full.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#call-opt-out-limit"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
"500": {
"description": "Internal server error",
"content": {
@@ -5953,43 +6056,61 @@
}
},
"x-mint": {
- "content": "sessions:manage"
+ "content": "sessions:read"
},
"x-audience": "public"
- }
- },
- "/platform/call-opt-outs/{optOutId}": {
- "delete": {
- "operationId": "deleteCallOptOut",
+ },
+ "post": {
+ "operationId": "createCallOptOut",
"tags": [
"Call Consent"
],
- "summary": "Remove a person from the do-not-call list",
- "description": "**Required scope:** `sessions:manage`\n\nRemoves one entry. The person can be called again.",
+ "summary": "Add a person to the do-not-call list",
+ "description": "**Required scope:** `sessions:manage`\n\nAdds a phone number or BSUID. Calls to it are refused with `403 call_recipient_opted_out` from then on. Adding an entry that already exists returns it with `200`.",
"security": [
{
"ApiKeyAuth": []
}
],
"x-required-scope": "sessions:manage",
- "parameters": [
- {
- "schema": {
- "type": "string",
- "format": "uuid"
- },
- "required": true,
- "name": "optOutId",
- "in": "path"
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessCreateCallOptOutRequest"
+ }
+ }
}
- ],
+ },
"responses": {
"200": {
- "description": "Entry removed.",
+ "description": "The entry already existed.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessDeleteCallOptOutResponse"
+ "$ref": "#/components/schemas/PlatformAccessExistingCallOptOutResponse"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "201": {
+ "description": "Entry added.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessCreateCallOptOutResponse"
}
}
},
@@ -6133,6 +6254,38 @@
}
}
},
+ "409": {
+ "description": "`call_opt_out_limit`: the list already holds 100,000 entries.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "conflict_error",
+ "code": "call_opt_out_limit",
+ "message": "The team's do-not-call list is full.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#call-opt-out-limit"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"content": {
@@ -6172,68 +6325,37 @@
"x-audience": "public"
}
},
- "/platform/voice/audio": {
- "get": {
- "operationId": "listVoiceAudio",
+ "/platform/call-opt-outs/import": {
+ "post": {
+ "operationId": "importCallOptOuts",
"tags": [
- "Voice"
+ "Call Consent"
],
- "summary": "List audio assets",
- "description": "**Required scope:** `voice:read`\n\nLists a project's audio assets, newest first.",
+ "summary": "Import do-not-call entries",
+ "description": "**Required scope:** `sessions:manage`\n\nAdds up to 5,000 entries in one request. Valid entries are added together; invalid ones are listed in `rejected`. If the valid entries would take the list past its limit, nothing is added and the request fails with `409 call_opt_out_limit`.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "voice:read",
- "parameters": [
- {
- "schema": {
- "type": "string",
- "format": "uuid",
- "description": "Project whose assets are listed. Required for organization keys."
- },
- "required": false,
- "name": "projectId",
- "in": "query"
- },
- {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessVoiceAudioStatus"
- },
- "required": false,
- "name": "status",
- "in": "query"
- },
- {
- "schema": {
- "type": "string",
- "maxLength": 200,
- "description": "`page.nextCursor` of the previous page."
- },
- "required": false,
- "name": "cursor",
- "in": "query"
- },
- {
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 100,
- "description": "1 to 100. Defaults to 50."
- },
- "required": false,
- "name": "limit",
- "in": "query"
+ "x-required-scope": "sessions:manage",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessImportCallOptOutsRequest"
+ }
+ }
}
- ],
+ },
"responses": {
"200": {
- "description": "A page of audio assets.",
+ "description": "Import result.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessListVoiceAudioResponse"
+ "$ref": "#/components/schemas/PlatformAccessImportCallOptOutsResponse"
}
}
},
@@ -6377,6 +6499,38 @@
}
}
},
+ "409": {
+ "description": "`call_opt_out_limit`: the entries would take the list past 100,000 entries.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "conflict_error",
+ "code": "call_opt_out_limit",
+ "message": "The team's do-not-call list is full.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#call-opt-out-limit"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"content": {
@@ -6411,40 +6565,43 @@
}
},
"x-mint": {
- "content": "voice:read"
+ "content": "sessions:manage"
},
"x-audience": "public"
- },
- "post": {
- "operationId": "createVoiceAudioUpload",
+ }
+ },
+ "/platform/call-opt-outs/{optOutId}": {
+ "delete": {
+ "operationId": "deleteCallOptOut",
"tags": [
- "Voice"
+ "Call Consent"
],
- "summary": "Upload an audio file",
- "description": "**Required scope:** `voice:manage`\n\nCreates an audio asset and returns a URL to send the file to. Send the file with `POST` and the returned headers within 5 minutes, then call `POST /platform/voice/audio/{assetId}/complete`. MP3, WAV, OGG (Opus or Vorbis) and M4A (AAC), up to 16 MB and 10 minutes of audio.",
+ "summary": "Remove a person from the do-not-call list",
+ "description": "**Required scope:** `sessions:manage`\n\nRemoves one entry. The person can be called again.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "voice:manage",
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateVoiceAudioUploadRequest"
- }
- }
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": true,
+ "name": "optOutId",
+ "in": "path"
}
- },
+ ],
"responses": {
- "201": {
- "description": "Asset created in `pending_upload`, with the upload URL.",
+ "200": {
+ "description": "Entry removed.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessCreateVoiceAudioUploadResponse"
+ "$ref": "#/components/schemas/PlatformAccessDeleteCallOptOutResponse"
}
}
},
@@ -6524,8 +6681,8 @@
}
}
},
- "402": {
- "description": "`gate_limit_reached`: a Voice Automation limit is enforced and reached.",
+ "403": {
+ "description": "Authenticated but not permitted (missing scope or rule violation)",
"content": {
"application/json": {
"schema": {
@@ -6534,13 +6691,13 @@
"example": {
"error": {
"type": "permission_error",
- "code": "gate_limit_reached",
- "message": "A usage limit for this team or number was reached.",
+ "code": "permission_denied",
+ "message": "The credential does not have access to this resource.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#gate-limit-reached"
+ "docs": "https://docs.polymorfa.com/api/errors#permission-denied"
}
}
},
@@ -6556,8 +6713,8 @@
}
}
},
- "403": {
- "description": "`voice_not_enabled`: the team is not enrolled in the Voice Automation beta. Also `permission_denied` and `missing_scope`.",
+ "404": {
+ "description": "Resource not found",
"content": {
"application/json": {
"schema": {
@@ -6565,14 +6722,14 @@
},
"example": {
"error": {
- "type": "permission_error",
- "code": "voice_not_enabled",
- "message": "The Voice Automation beta is not enabled for this team.",
+ "type": "invalid_request_error",
+ "code": "resource_not_found",
+ "message": "The resource is unavailable to this caller.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#voice-not-enabled"
+ "docs": "https://docs.polymorfa.com/api/errors#resource-not-found"
}
}
},
@@ -6588,8 +6745,124 @@
}
}
},
- "404": {
- "description": "Resource not found",
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "internal_error",
+ "message": "An unexpected service failure occurred. Keep the request ID for support.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#internal-error"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ }
+ },
+ "x-mint": {
+ "content": "sessions:manage"
+ },
+ "x-audience": "public"
+ }
+ },
+ "/platform/voice/audio": {
+ "get": {
+ "operationId": "listVoiceAudio",
+ "tags": [
+ "Voice"
+ ],
+ "summary": "List audio assets",
+ "description": "**Required scope:** `voice:read`\n\nLists a project's audio assets, newest first.",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "x-required-scope": "voice:read",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "description": "Project whose assets are listed. Required for organization keys."
+ },
+ "required": false,
+ "name": "projectId",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessVoiceAudioStatus"
+ },
+ "required": false,
+ "name": "status",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "maxLength": 200,
+ "description": "`page.nextCursor` of the previous page."
+ },
+ "required": false,
+ "name": "cursor",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "1 to 100. Defaults to 50."
+ },
+ "required": false,
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A page of audio assets.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessListVoiceAudioResponse"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request — request body or params failed validation",
"content": {
"application/json": {
"schema": {
@@ -6598,13 +6871,13 @@
"example": {
"error": {
"type": "invalid_request_error",
- "code": "resource_not_found",
- "message": "The resource is unavailable to this caller.",
+ "code": "invalid_parameter",
+ "message": "Correct the invalid request field.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#resource-not-found"
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter"
}
}
},
@@ -6620,8 +6893,8 @@
}
}
},
- "409": {
- "description": "`state_conflict`: the project already holds 500 audio assets.",
+ "401": {
+ "description": "Missing or invalid authentication token",
"content": {
"application/json": {
"schema": {
@@ -6629,14 +6902,14 @@
},
"example": {
"error": {
- "type": "conflict_error",
- "code": "state_conflict",
- "message": "The request conflicts with the resource state.",
+ "type": "authentication_error",
+ "code": "invalid_credential",
+ "message": "Supply a valid credential.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#state-conflict"
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-credential"
}
}
},
@@ -6652,8 +6925,8 @@
}
}
},
- "500": {
- "description": "Internal server error",
+ "403": {
+ "description": "Authenticated but not permitted (missing scope or rule violation)",
"content": {
"application/json": {
"schema": {
@@ -6661,14 +6934,14 @@
},
"example": {
"error": {
- "type": "api_error",
- "code": "internal_error",
- "message": "An unexpected service failure occurred. Keep the request ID for support.",
+ "type": "permission_error",
+ "code": "permission_denied",
+ "message": "The credential does not have access to this resource.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#internal-error"
+ "docs": "https://docs.polymorfa.com/api/errors#permission-denied"
}
}
},
@@ -6684,8 +6957,40 @@
}
}
},
- "503": {
- "description": "`voice_unavailable`: audio storage is not configured. `provider_unavailable`: the voice provider did not answer.",
+ "404": {
+ "description": "Resource not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_not_found",
+ "message": "The resource is unavailable to this caller.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-not-found"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
"content": {
"application/json": {
"schema": {
@@ -6694,13 +6999,13 @@
"example": {
"error": {
"type": "api_error",
- "code": "voice_unavailable",
- "message": "Voice Automation storage is not available.",
+ "code": "internal_error",
+ "message": "An unexpected service failure occurred. Keep the request ID for support.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#voice-unavailable"
+ "docs": "https://docs.polymorfa.com/api/errors#internal-error"
}
}
},
@@ -6718,19 +7023,17 @@
}
},
"x-mint": {
- "content": "voice:manage"
+ "content": "voice:read"
},
"x-audience": "public"
- }
- },
- "/platform/voice/audio/tts": {
+ },
"post": {
- "operationId": "synthesizeVoiceAudio",
+ "operationId": "createVoiceAudioUpload",
"tags": [
"Voice"
],
- "summary": "Generate speech",
- "description": "**Required scope:** `voice:manage`\n\nGenerates speech from text with ElevenLabs or OpenAI and stores it as an audio asset. Without `credentialId` Polymorfa's provider key is used; with it, your stored key for that provider. The provider processes the text. The asset converts asynchronously like an upload.",
+ "summary": "Upload an audio file",
+ "description": "**Required scope:** `voice:manage`\n\nCreates an audio asset and returns a URL to send the file to. Send the file with `POST` and the returned headers within 5 minutes, then call `POST /platform/voice/audio/{assetId}/complete`. MP3, WAV, OGG (Opus or Vorbis) and M4A (AAC), up to 16 MB and 10 minutes of audio.",
"security": [
{
"ApiKeyAuth": []
@@ -6742,18 +7045,18 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessSynthesizeVoiceAudioRequest"
+ "$ref": "#/components/schemas/PlatformAccessCreateVoiceAudioUploadRequest"
}
}
}
},
"responses": {
"201": {
- "description": "Asset created, now `transcoding`.",
+ "description": "Asset created in `pending_upload`, with the upload URL.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessSynthesizeVoiceAudioResponse"
+ "$ref": "#/components/schemas/PlatformAccessCreateVoiceAudioUploadResponse"
}
}
},
@@ -6961,38 +7264,6 @@
}
}
},
- "422": {
- "description": "`provider_credential_invalid`: the provider rejected your stored key.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "invalid_request_error",
- "code": "provider_credential_invalid",
- "message": "The voice provider rejected the key.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#provider-credential-invalid"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
"500": {
"description": "Internal server error",
"content": {
@@ -7064,38 +7335,37 @@
"x-audience": "public"
}
},
- "/platform/voice/audio/{assetId}/complete": {
+ "/platform/voice/audio/tts": {
"post": {
- "operationId": "completeVoiceAudioUpload",
+ "operationId": "synthesizeVoiceAudio",
"tags": [
"Voice"
],
- "summary": "Complete an audio upload",
- "description": "**Required scope:** `voice:manage`\n\nConfirms that the file was sent and starts conversion. The asset moves to `transcoding`, then to `ready` or `failed`; the `voice.asset_ready` or `voice.asset_failed` webhook reports the result.",
+ "summary": "Generate speech",
+ "description": "**Required scope:** `voice:manage`\n\nGenerates speech from text with ElevenLabs or OpenAI and stores it as an audio asset. Without `credentialId` Polymorfa's provider key is used; with it, your stored key for that provider. The provider processes the text. The asset converts asynchronously like an upload.",
"security": [
{
"ApiKeyAuth": []
}
],
"x-required-scope": "voice:manage",
- "parameters": [
- {
- "schema": {
- "type": "string",
- "format": "uuid"
- },
- "required": true,
- "name": "assetId",
- "in": "path"
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessSynthesizeVoiceAudioRequest"
+ }
+ }
}
- ],
+ },
"responses": {
- "200": {
- "description": "The asset, now `transcoding`.",
+ "201": {
+ "description": "Asset created, now `transcoding`.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessCompleteVoiceAudioUploadResponse"
+ "$ref": "#/components/schemas/PlatformAccessSynthesizeVoiceAudioResponse"
}
}
},
@@ -7175,6 +7445,38 @@
}
}
},
+ "402": {
+ "description": "`gate_limit_reached`: a Voice Automation limit is enforced and reached.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "permission_error",
+ "code": "gate_limit_reached",
+ "message": "A usage limit for this team or number was reached.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#gate-limit-reached"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"403": {
"description": "`voice_not_enabled`: the team is not enrolled in the Voice Automation beta. Also `permission_denied` and `missing_scope`.",
"content": {
@@ -7240,7 +7542,7 @@
}
},
"409": {
- "description": "`state_conflict`: the asset is not waiting for an upload, or the file was not received.",
+ "description": "`state_conflict`: the project already holds 500 audio assets.",
"content": {
"application/json": {
"schema": {
@@ -7271,6 +7573,38 @@
}
}
},
+ "422": {
+ "description": "`provider_credential_invalid`: the provider rejected your stored key.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "provider_credential_invalid",
+ "message": "The voice provider rejected the key.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#provider-credential-invalid"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"content": {
@@ -7342,19 +7676,20 @@
"x-audience": "public"
}
},
- "/platform/voice/audio/{assetId}": {
- "get": {
- "operationId": "getVoiceAudio",
+ "/platform/voice/audio/{assetId}/complete": {
+ "post": {
+ "operationId": "completeVoiceAudioUpload",
"tags": [
"Voice"
],
- "summary": "Get an audio asset",
+ "summary": "Complete an audio upload",
+ "description": "**Required scope:** `voice:manage`\n\nConfirms that the file was sent and starts conversion. The asset moves to `transcoding`, then to `ready` or `failed`; the `voice.asset_ready` or `voice.asset_failed` webhook reports the result.",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "voice:read",
+ "x-required-scope": "voice:manage",
"parameters": [
{
"schema": {
@@ -7368,11 +7703,11 @@
],
"responses": {
"200": {
- "description": "The audio asset.",
+ "description": "The asset, now `transcoding`.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessGetVoiceAudioResponse"
+ "$ref": "#/components/schemas/PlatformAccessCompleteVoiceAudioUploadResponse"
}
}
},
@@ -7453,7 +7788,7 @@
}
},
"403": {
- "description": "Authenticated but not permitted (missing scope or rule violation)",
+ "description": "`voice_not_enabled`: the team is not enrolled in the Voice Automation beta. Also `permission_denied` and `missing_scope`.",
"content": {
"application/json": {
"schema": {
@@ -7462,13 +7797,13 @@
"example": {
"error": {
"type": "permission_error",
- "code": "permission_denied",
- "message": "The credential does not have access to this resource.",
+ "code": "voice_not_enabled",
+ "message": "The Voice Automation beta is not enabled for this team.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#permission-denied"
+ "docs": "https://docs.polymorfa.com/api/errors#voice-not-enabled"
}
}
},
@@ -7516,6 +7851,38 @@
}
}
},
+ "409": {
+ "description": "`state_conflict`: the asset is not waiting for an upload, or the file was not received.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "conflict_error",
+ "code": "state_conflict",
+ "message": "The request conflicts with the resource state.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#state-conflict"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"content": {
@@ -7547,27 +7914,59 @@
"$ref": "#/components/headers/X-RateLimit-Reset"
}
}
+ },
+ "503": {
+ "description": "`voice_unavailable`: audio storage is not configured. `provider_unavailable`: the voice provider did not answer.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "voice_unavailable",
+ "message": "Voice Automation storage is not available.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#voice-unavailable"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
}
},
"x-mint": {
- "content": "voice:read"
+ "content": "voice:manage"
},
- "description": "**Required scope:** `voice:read`",
"x-audience": "public"
- },
- "patch": {
- "operationId": "updateVoiceAudio",
+ }
+ },
+ "/platform/voice/audio/{assetId}": {
+ "get": {
+ "operationId": "getVoiceAudio",
"tags": [
"Voice"
],
- "summary": "Update an audio asset",
- "description": "**Required scope:** `voice:manage`\n\nRenames an asset or changes its retention. Send `expectedRevision` to refuse the change with `409 voice_asset_revision_conflict` when the asset changed since you read it.",
+ "summary": "Get an audio asset",
"security": [
{
"ApiKeyAuth": []
}
],
- "x-required-scope": "voice:manage",
+ "x-required-scope": "voice:read",
"parameters": [
{
"schema": {
@@ -7579,23 +7978,13 @@
"in": "path"
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateVoiceAudioRequest"
- }
- }
- }
- },
"responses": {
"200": {
- "description": "The updated asset.",
+ "description": "The audio asset.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessUpdateVoiceAudioResponse"
+ "$ref": "#/components/schemas/PlatformAccessGetVoiceAudioResponse"
}
}
},
@@ -7676,7 +8065,7 @@
}
},
"403": {
- "description": "`voice_not_enabled`: the team is not enrolled in the Voice Automation beta. Also `permission_denied` and `missing_scope`.",
+ "description": "Authenticated but not permitted (missing scope or rule violation)",
"content": {
"application/json": {
"schema": {
@@ -7685,13 +8074,13 @@
"example": {
"error": {
"type": "permission_error",
- "code": "voice_not_enabled",
- "message": "The Voice Automation beta is not enabled for this team.",
+ "code": "permission_denied",
+ "message": "The credential does not have access to this resource.",
"param": null,
"request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
},
"data": null,
- "docs": "https://docs.polymorfa.com/api/errors#voice-not-enabled"
+ "docs": "https://docs.polymorfa.com/api/errors#permission-denied"
}
}
},
@@ -7739,38 +8128,6 @@
}
}
},
- "409": {
- "description": "`voice_asset_revision_conflict`: the asset changed after `expectedRevision`.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/PlatformAccessPublicError"
- },
- "example": {
- "error": {
- "type": "conflict_error",
- "code": "voice_asset_revision_conflict",
- "message": "The audio asset changed since it was read.",
- "param": null,
- "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
- },
- "data": null,
- "docs": "https://docs.polymorfa.com/api/errors#voice-asset-revision-conflict"
- }
- }
- },
- "headers": {
- "X-RateLimit-Limit": {
- "$ref": "#/components/headers/X-RateLimit-Limit"
- },
- "X-RateLimit-Remaining": {
- "$ref": "#/components/headers/X-RateLimit-Remaining"
- },
- "X-RateLimit-Reset": {
- "$ref": "#/components/headers/X-RateLimit-Reset"
- }
- }
- },
"500": {
"description": "Internal server error",
"content": {
@@ -7805,17 +8162,18 @@
}
},
"x-mint": {
- "content": "voice:manage"
+ "content": "voice:read"
},
+ "description": "**Required scope:** `voice:read`",
"x-audience": "public"
},
- "delete": {
- "operationId": "deleteVoiceAudio",
+ "patch": {
+ "operationId": "updateVoiceAudio",
"tags": [
"Voice"
],
- "summary": "Delete an audio asset",
- "description": "**Required scope:** `voice:manage`\n\nDeletes the asset and its stored audio. Returns `409 voice_asset_in_use` while a campaign or flow uses it.",
+ "summary": "Update an audio asset",
+ "description": "**Required scope:** `voice:manage`\n\nRenames an asset or changes its retention. Send `expectedRevision` to refuse the change with `409 voice_asset_revision_conflict` when the asset changed since you read it.",
"security": [
{
"ApiKeyAuth": []
@@ -7833,13 +8191,267 @@
"in": "path"
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessUpdateVoiceAudioRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
- "description": "Asset deleted.",
+ "description": "The updated asset.",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/PlatformAccessDeleteVoiceAudioResponse"
+ "$ref": "#/components/schemas/PlatformAccessUpdateVoiceAudioResponse"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request — request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "invalid_parameter",
+ "message": "Correct the invalid request field.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "authentication_error",
+ "code": "invalid_credential",
+ "message": "Supply a valid credential.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-credential"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "403": {
+ "description": "`voice_not_enabled`: the team is not enrolled in the Voice Automation beta. Also `permission_denied` and `missing_scope`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "permission_error",
+ "code": "voice_not_enabled",
+ "message": "The Voice Automation beta is not enabled for this team.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#voice-not-enabled"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "404": {
+ "description": "Resource not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_not_found",
+ "message": "The resource is unavailable to this caller.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-not-found"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "409": {
+ "description": "`voice_asset_revision_conflict`: the asset changed after `expectedRevision`.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "conflict_error",
+ "code": "voice_asset_revision_conflict",
+ "message": "The audio asset changed since it was read.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#voice-asset-revision-conflict"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "internal_error",
+ "message": "An unexpected service failure occurred. Keep the request ID for support.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#internal-error"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ }
+ },
+ "x-mint": {
+ "content": "voice:manage"
+ },
+ "x-audience": "public"
+ },
+ "delete": {
+ "operationId": "deleteVoiceAudio",
+ "tags": [
+ "Voice"
+ ],
+ "summary": "Delete an audio asset",
+ "description": "**Required scope:** `voice:manage`\n\nDeletes the asset and its stored audio. Returns `409 voice_asset_in_use` while a campaign or flow uses it.",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "x-required-scope": "voice:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "required": true,
+ "name": "assetId",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Asset deleted.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessDeleteVoiceAudioResponse"
}
}
},
@@ -37806,7 +38418,7 @@
"projects"
],
"summary": "Read project log forwarding",
- "description": "Project-scoped dashboard configuration. Returns destination URLs and header names, never saved header values or signing secrets.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
+ "description": "Project-scoped dashboard configuration. Owners and admins receive destination URLs and header names. Members receive metadata with null URLs and empty header names. Saved header values and signing secrets are never returned.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
@@ -37924,6 +38536,10 @@
"required": [
"id",
"name",
+ "dataset",
+ "sessionId",
+ "windowHours",
+ "encoding",
"url",
"enabled",
"revision",
@@ -37942,9 +38558,37 @@
"name": {
"type": "string"
},
+ "dataset": {
+ "type": "string",
+ "enum": [
+ "logs",
+ "analytics"
+ ]
+ },
+ "encoding": {
+ "type": "string",
+ "enum": [
+ "json",
+ "ndjson"
+ ]
+ },
+ "sessionId": {
+ "type": "string",
+ "format": "uuid",
+ "nullable": true
+ },
+ "windowHours": {
+ "type": "integer",
+ "enum": [
+ 1,
+ 24,
+ 168
+ ]
+ },
"url": {
"type": "string",
- "format": "uri"
+ "format": "uri",
+ "nullable": true
},
"enabled": {
"type": "boolean"
@@ -38234,6 +38878,33 @@
"minLength": 1,
"maxLength": 80
},
+ "dataset": {
+ "type": "string",
+ "enum": [
+ "logs",
+ "analytics"
+ ]
+ },
+ "encoding": {
+ "type": "string",
+ "enum": [
+ "json",
+ "ndjson"
+ ]
+ },
+ "sessionId": {
+ "type": "string",
+ "format": "uuid",
+ "nullable": true
+ },
+ "windowHours": {
+ "type": "integer",
+ "enum": [
+ 1,
+ 24,
+ 168
+ ]
+ },
"enabled": {
"type": "boolean"
},
@@ -38608,18 +39279,18 @@
}
}
},
- "/console/insights/number-options": {
+ "/console/insights/vitals": {
"get": {
- "operationId": "listConsoleNumberOptions",
+ "operationId": "getConsoleErrorVitals",
"tags": [
"insights"
],
- "summary": "Search Console number options",
- "description": "Private dashboard-only, org-scoped production number labels for selectors. Optional projectId must name an active, nondeleting project. Search is a literal case-insensitive substring of display name, current phone, session name or immutable number UUID, evaluated before pagination. Numbers in testing, deletion or deleted status and inactive/deleting projects are excluded; expired number history remains selectable. Results use immutable UUID ascending keyset order, default 25 and maximum 100; a cursor is bound to org, project and normalized search. It is navigation only and never grants authority. Response includes only number ID, project ID and a display label, with no configuration, owner, customer or provider fields. Public session listing is unchanged.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
+ "summary": "Read Console API and webhook errors",
+ "description": "Dashboard-only counts for the last 24 hours, independent of Analytics consent. Project scope is verified through live dashboard authority.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
- "x-exclusion-reason": "Dashboard-only bounded, searchable number labels; server credentials cannot use the selector read.",
+ "x-dashboard-only": true,
"security": [
{
"ConsoleSession": []
@@ -38631,39 +39302,275 @@
"in": "query",
"required": false,
"description": "Project to read. Omit to total every project in the organization.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "headers": {
+ "X-Request-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stable request correlation identifier."
+ },
+ "Polymorfa-Version": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "API version applied to this response."
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "period",
+ "api",
+ "webhooks"
+ ],
+ "properties": {
+ "period": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "integer"
+ },
+ "end": {
+ "type": "integer"
+ }
+ }
+ },
+ "api": {
+ "type": "object",
+ "properties": {
+ "requests": {
+ "type": "integer"
+ },
+ "failures": {
+ "type": "integer"
+ },
+ "errorRate": {
+ "type": "number",
+ "nullable": true
+ }
+ }
+ },
+ "webhooks": {
+ "type": "object",
+ "properties": {
+ "attempts": {
+ "type": "integer"
+ },
+ "failures": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "data"
+ ]
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ }
+ }
+ },
+ "/platform/analytics": {
+ "get": {
+ "operationId": "getWhatsAppAnalytics",
+ "tags": [
+ "Analytics"
+ ],
+ "summary": "Get WhatsApp number analytics",
+ "description": "Aggregated number activity and operational vitals. Returns disabled with null summary and empty data until a team owner or admin enables Analytics. Conversation rates use completed 24-hour direct-chat cohorts, booked in the period when they mature; missing or dropped cohort coverage returns null rates. Read means an observed read receipt, not a verified app open. No contact identifiers or message content. Linked Devices telemetry only; unsupported or unobserved numbers carry measured=false. Bucket timestamps use UTC. Activity series use hourly buckets for ranges up to 48 hours and daily buckets for longer ranges. Call series use daily buckets. Message metadata compares direct sends and completed conversations by type, coarse text/caption length, source, calling code and observed device count. Device observations are bounded samples. Calling-code shares describe conversations, not distinct people or location. Presence signals require existing subscriptions. Primary phone activity spans end at their last observed signal and close after 15 minutes without evidence. Quiet gaps end at the next observed signal. Duration sums and averages count completed intervals and exclude open or restart-censored intervals; they do not measure phone online/offline or power state. Metadata collection uses the current team opt-in; disable erases these aggregates and stale queued metadata is rejected. Calls aggregate retained records by their immutable original number/project ownership, including supported Official API calls; message measured/coverage fields do not apply to calls. Call series groups starts by UTC day. Answer rate excludes still-ringing calls; timing fields have explicit observation counts. Missed-call follow-up observes an answered outgoing call to the same pseudonymous contact within 24h, including after the requested end; only completed windows with a known contact form the denominator. It does not prove callback intent. Quality uses reported measurements, latest cumulative app reports per connection and per-call averages. Breakdown counts can overlap. Disabling hides this call view without deleting operational call history.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.",
+ "x-audience": "public",
+ "x-polymorfa-resource-boundary": "project",
+ "x-excluded-from-public": false,
+ "x-required-scope": "sessions:read",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "ConsoleSession": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "projectId",
+ "in": "query",
+ "required": false,
+ "description": "Project to read. Omit to total every project in the organization.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "sessionId",
+ "in": "query",
+ "required": false,
"schema": {
"type": "string",
- "format": "uuid"
+ "format": "uuid",
+ "description": "Number record UUID."
}
},
{
- "name": "search",
+ "name": "start",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Unix milliseconds; maximum range 366 days."
+ }
+ },
+ {
+ "name": "end",
"in": "query",
"required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Unix milliseconds; maximum range 366 days."
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "headers": {
+ "X-Request-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stable request correlation identifier."
+ },
+ "Polymorfa-Version": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "API version applied to this response."
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/WhatsAppAnalytics"
+ }
+ },
+ "required": [
+ "data"
+ ]
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "503": {
+ "$ref": "#/components/responses/PropagationPending"
+ }
+ }
+ }
+ },
+ "/platform/projects/{projectId}/analytics": {
+ "get": {
+ "operationId": "getProjectWhatsAppAnalytics",
+ "tags": [
+ "Analytics"
+ ],
+ "summary": "Get WhatsApp number analytics",
+ "description": "Aggregated number activity and operational vitals. Returns disabled with null summary and empty data until a team owner or admin enables Analytics. Conversation rates use completed 24-hour direct-chat cohorts, booked in the period when they mature; missing or dropped cohort coverage returns null rates. Read means an observed read receipt, not a verified app open. No contact identifiers or message content. Linked Devices telemetry only; unsupported or unobserved numbers carry measured=false. Bucket timestamps use UTC. Activity series use hourly buckets for ranges up to 48 hours and daily buckets for longer ranges. Call series use daily buckets. Message metadata compares direct sends and completed conversations by type, coarse text/caption length, source, calling code and observed device count. Device observations are bounded samples. Calling-code shares describe conversations, not distinct people or location. Presence signals require existing subscriptions. Primary phone activity spans end at their last observed signal and close after 15 minutes without evidence. Quiet gaps end at the next observed signal. Duration sums and averages count completed intervals and exclude open or restart-censored intervals; they do not measure phone online/offline or power state. Metadata collection uses the current team opt-in; disable erases these aggregates and stale queued metadata is rejected. Calls aggregate retained records by their immutable original number/project ownership, including supported Official API calls; message measured/coverage fields do not apply to calls. Call series groups starts by UTC day. Answer rate excludes still-ringing calls; timing fields have explicit observation counts. Missed-call follow-up observes an answered outgoing call to the same pseudonymous contact within 24h, including after the requested end; only completed windows with a known contact form the denominator. It does not prove callback intent. Quality uses reported measurements, latest cumulative app reports per connection and per-call averages. Breakdown counts can overlap. Disabling hides this call view without deleting operational call history.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.",
+ "x-audience": "public",
+ "x-polymorfa-resource-boundary": "project",
+ "x-excluded-from-public": false,
+ "x-required-scope": "sessions:read",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "ConsoleSession": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "projectId",
+ "in": "path",
+ "required": true,
"schema": {
"type": "string",
- "maxLength": 200
+ "format": "uuid"
}
},
{
- "name": "cursor",
+ "name": "sessionId",
"in": "query",
"required": false,
"schema": {
"type": "string",
- "minLength": 1,
- "maxLength": 2048
+ "format": "uuid",
+ "description": "Number record UUID."
}
},
{
- "name": "limit",
+ "name": "start",
"in": "query",
"required": false,
"schema": {
"type": "integer",
- "minimum": 1,
- "maximum": 100,
- "default": 25
+ "minimum": 0,
+ "description": "Unix milliseconds; maximum range 366 days."
+ }
+ },
+ {
+ "name": "end",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Unix milliseconds; maximum range 366 days."
}
}
],
@@ -38691,7 +39598,7 @@
"additionalProperties": false,
"properties": {
"data": {
- "$ref": "#/components/schemas/ConsoleNumberOptionsPage"
+ "$ref": "#/components/schemas/WhatsAppAnalytics"
}
},
"required": [
@@ -38712,22 +39619,25 @@
},
"404": {
"$ref": "#/components/responses/NotFound"
+ },
+ "503": {
+ "$ref": "#/components/responses/PropagationPending"
}
}
}
},
- "/console/insights/usage": {
+ "/console/insights/number-options": {
"get": {
- "operationId": "usageInsight",
+ "operationId": "listConsoleNumberOptions",
"tags": [
"insights"
],
- "summary": "Read Console usage",
- "description": "Private Console usage for the authenticated team or one active project. `start` and `end` are inclusive Unix-millisecond boundaries, with a maximum span of 366 days. A missing end defaults to now; a missing start defaults to 29 days before the end. Daily buckets use UTC dates and include only dates with observed audit or credit-ledger activity. API request counts include API and MCP operations; peak request rate is the busiest one-minute bucket, not a sum of daily peaks. Number and connection metrics are live snapshots, and message-rate usage remains null because its peak is not measured. The project breakdown includes active projects and omits unattributed team activity, which remains in team totals.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
+ "summary": "Search Console number options",
+ "description": "Private dashboard-only, org-scoped production number labels for selectors. Optional projectId must name an active, nondeleting project. Search is a literal case-insensitive substring of display name, current phone, session name or immutable number UUID, evaluated before pagination. Numbers in testing, deletion or deleted status and inactive/deleting projects are excluded; expired number history remains selectable. Results use immutable UUID ascending keyset order, default 25 and maximum 100; a cursor is bound to org, project and normalized search. It is navigation only and never grants authority. Response includes only number ID, project ID and a display label, with no configuration, owner, customer or provider fields. Public session listing is unchanged.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
- "x-exclusion-reason": "Customer console usage and entitlement presentation.",
+ "x-exclusion-reason": "Dashboard-only bounded, searchable number labels; server credentials cannot use the selector read.",
"security": [
{
"ConsoleSession": []
@@ -38745,33 +39655,141 @@
}
},
{
- "name": "numberId",
+ "name": "search",
"in": "query",
"required": false,
- "description": "Immutable production number UUID. Requires projectId; missing, deleted, testing or mismatched numbers return 404. Filters only immutable attributed records; request logs and serverless receipts have no number attribution.",
"schema": {
"type": "string",
- "format": "uuid"
+ "maxLength": 200
}
},
{
- "name": "start",
+ "name": "cursor",
"in": "query",
"required": false,
"schema": {
- "type": "integer",
- "format": "int64",
- "description": "Inclusive Unix-millisecond boundary. The range may span at most 366 days."
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048
}
},
{
- "name": "end",
+ "name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
- "format": "int64",
- "description": "Inclusive Unix-millisecond boundary. The range may span at most 366 days."
+ "minimum": 1,
+ "maximum": 100,
+ "default": 25
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "headers": {
+ "X-Request-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stable request correlation identifier."
+ },
+ "Polymorfa-Version": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "API version applied to this response."
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/ConsoleNumberOptionsPage"
+ }
+ },
+ "required": [
+ "data"
+ ]
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ }
+ }
+ }
+ },
+ "/console/insights/usage": {
+ "get": {
+ "operationId": "usageInsight",
+ "tags": [
+ "insights"
+ ],
+ "summary": "Read Console usage",
+ "description": "Private Console usage for the authenticated team or one active project. `start` and `end` are inclusive Unix-millisecond boundaries, with a maximum span of 366 days. A missing end defaults to now; a missing start defaults to 29 days before the end. Daily buckets use UTC dates and include only dates with observed audit or credit-ledger activity. API request counts include API and MCP operations; peak request rate is the busiest one-minute bucket, not a sum of daily peaks. Number and connection metrics are live snapshots, and message-rate usage remains null because its peak is not measured. The project breakdown includes active projects and omits unattributed team activity, which remains in team totals.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
+ "x-audience": "console",
+ "x-polymorfa-resource-boundary": "human_only",
+ "x-excluded-from-public": true,
+ "x-exclusion-reason": "Customer console usage and entitlement presentation.",
+ "security": [
+ {
+ "ConsoleSession": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "projectId",
+ "in": "query",
+ "required": false,
+ "description": "Project to read. Omit to total every project in the organization.",
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "numberId",
+ "in": "query",
+ "required": false,
+ "description": "Immutable production number UUID. Requires projectId; missing, deleted, testing or mismatched numbers return 404. Filters only immutable attributed records; request logs and serverless receipts have no number attribution.",
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "start",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Inclusive Unix-millisecond boundary. The range may span at most 366 days."
+ }
+ },
+ {
+ "name": "end",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Inclusive Unix-millisecond boundary. The range may span at most 366 days."
}
}
],
@@ -38831,7 +39849,7 @@
"insights"
],
"summary": "Analytics insight",
- "description": "**EXCLUDED from the public API** (`console`). Customer console aggregate product analytics; message bodies are never returned.",
+ "description": "Org/project-scoped recorded API, event, webhook and number analytics. Event and webhook counts include unexpired metadata only; current snapshots are independent of dates. No message bodies or identity fields are returned.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
@@ -38888,19 +39906,34 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DataEnvelope"
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/ConsoleAnalytics"
+ }
+ },
+ "required": [
+ "data"
+ ]
}
}
}
},
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
- "501": {
- "$ref": "#/components/responses/NotImplemented"
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "503": {
+ "$ref": "#/components/responses/PropagationPending"
}
}
}
@@ -38912,11 +39945,11 @@
"insights"
],
"summary": "Get insight preferences",
- "description": "**EXCLUDED from the public API** (`console`). Organization analytics-consent controls for the console.",
+ "description": "undefined\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
- "x-exclusion-reason": "Organization analytics-consent controls for the console.",
+ "x-exclusion-reason": "Team analytics enablement for the Console; owner/admin writes only.",
"security": [
{
"ConsoleSession": []
@@ -38942,7 +39975,16 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DataEnvelope"
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/AnalyticsSettings"
+ }
+ },
+ "required": [
+ "data"
+ ]
}
}
}
@@ -38953,8 +39995,8 @@
"403": {
"$ref": "#/components/responses/Forbidden"
},
- "501": {
- "$ref": "#/components/responses/NotImplemented"
+ "404": {
+ "$ref": "#/components/responses/NotFound"
}
}
},
@@ -38964,23 +40006,22 @@
"insights"
],
"summary": "Update insight preferences",
- "description": "**EXCLUDED from the public API** (`console`). Organization analytics-consent controls for the console.",
+ "description": "undefined\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.",
"x-audience": "console",
"x-polymorfa-resource-boundary": "human_only",
"x-excluded-from-public": true,
- "x-exclusion-reason": "Organization analytics-consent controls for the console.",
+ "x-exclusion-reason": "Team analytics enablement for the Console; owner/admin writes only.",
"security": [
{
"ConsoleSession": []
}
],
"requestBody": {
- "required": false,
+ "required": true,
"content": {
"application/json": {
"schema": {
- "type": "object",
- "additionalProperties": true
+ "$ref": "#/components/schemas/AnalyticsSettings"
}
}
}
@@ -39005,19 +40046,31 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DataEnvelope"
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/AnalyticsSettings"
+ }
+ },
+ "required": [
+ "data"
+ ]
}
}
}
},
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
- "501": {
- "$ref": "#/components/responses/NotImplemented"
+ "404": {
+ "$ref": "#/components/responses/NotFound"
}
}
}
@@ -40732,6 +41785,2934 @@
}
}
},
+ "ConsoleAnalytics": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "period": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "start": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "end": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ }
+ },
+ "required": [
+ "start",
+ "end"
+ ]
+ },
+ "consent": {
+ "$ref": "#/components/schemas/AnalyticsSettings"
+ },
+ "series": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "ts": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "apiRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "durationSumMs": {
+ "type": "number",
+ "minimum": 0
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sessionDays": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "credits": {
+ "type": "number",
+ "minimum": 0
+ },
+ "newNumbers": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "ts",
+ "apiRequests",
+ "failures",
+ "timedRequests",
+ "avgLatencyMs",
+ "durationSumMs",
+ "p95LatencyMs",
+ "sessionDays",
+ "credits",
+ "newNumbers"
+ ]
+ }
+ },
+ "summary": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "apiRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "sessionDays": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "credits": {
+ "type": "number",
+ "minimum": 0
+ },
+ "newNumbers": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "apiRequests",
+ "failureRate",
+ "sessionDays",
+ "credits",
+ "newNumbers"
+ ]
+ },
+ "byProject": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "projectId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "apiRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "sessionDays": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "credits": {
+ "type": "number",
+ "minimum": 0
+ },
+ "newNumbers": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "projectId",
+ "apiRequests",
+ "failureRate",
+ "sessionDays",
+ "credits",
+ "newNumbers"
+ ]
+ }
+ },
+ "accountSignals": {
+ "nullable": true,
+ "allOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "totalNumbers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "businessAccounts": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true
+ },
+ "platformCoverage": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "platforms": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ "required": [
+ "totalNumbers",
+ "businessAccounts",
+ "platformCoverage",
+ "platforms"
+ ]
+ }
+ ]
+ },
+ "detail": {
+ "$ref": "#/components/schemas/ConsoleAnalyticsDetail"
+ }
+ },
+ "required": [
+ "period",
+ "consent",
+ "series",
+ "summary",
+ "accountSignals",
+ "detail"
+ ]
+ },
+ "ConsoleAnalyticsDetail": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "operations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p50LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ }
+ },
+ "required": [
+ "value",
+ "requests",
+ "failures",
+ "timedRequests",
+ "avgLatencyMs",
+ "p50LatencyMs",
+ "p95LatencyMs"
+ ]
+ }
+ },
+ "statuses": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p50LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ }
+ },
+ "required": [
+ "value",
+ "requests",
+ "failures",
+ "timedRequests",
+ "avgLatencyMs",
+ "p50LatencyMs",
+ "p95LatencyMs"
+ ]
+ }
+ },
+ "credentials": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p50LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ }
+ },
+ "required": [
+ "value",
+ "requests",
+ "failures",
+ "timedRequests",
+ "avgLatencyMs",
+ "p50LatencyMs",
+ "p95LatencyMs"
+ ]
+ }
+ },
+ "sources": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedRequests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p50LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ }
+ },
+ "required": [
+ "value",
+ "requests",
+ "failures",
+ "timedRequests",
+ "avgLatencyMs",
+ "p50LatencyMs",
+ "p95LatencyMs"
+ ]
+ }
+ },
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "hours": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "weekday": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 7
+ },
+ "hour": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 23
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "weekday",
+ "hour",
+ "requests",
+ "failures"
+ ]
+ }
+ },
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "firstAt": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "lastAt": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ }
+ },
+ "required": [
+ "type",
+ "source",
+ "count",
+ "firstAt",
+ "lastAt"
+ ]
+ },
+ "description": "Public event metadata still retained in the requested range. Counts are events, not distinct messages or recipients."
+ },
+ "deliveries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "retried": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "attempts": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "status",
+ "count",
+ "retried",
+ "attempts"
+ ]
+ },
+ "description": "Unexpired deliveries created in the requested range, grouped by current status. Attempts are lifetime totals for that cohort."
+ },
+ "attempts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "httpStatus": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAttempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avgLatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ },
+ "p95LatencyMs": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true,
+ "description": "Milliseconds; null when no timed observations exist."
+ }
+ },
+ "required": [
+ "status",
+ "httpStatus",
+ "count",
+ "timedAttempts",
+ "avgLatencyMs",
+ "p95LatencyMs"
+ ]
+ },
+ "description": "Unexpired webhook attempts started in the requested range (created time when not started)."
+ },
+ "fleet": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "backend": {
+ "type": "string"
+ },
+ "testMode": {
+ "type": "boolean"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "status",
+ "backend",
+ "testMode",
+ "count"
+ ]
+ },
+ "description": "Current number snapshot, independent of the date range."
+ }
+ },
+ "required": [
+ "operations",
+ "statuses",
+ "credentials",
+ "sources",
+ "errors",
+ "hours",
+ "events",
+ "deliveries",
+ "attempts",
+ "fleet"
+ ]
+ },
+ "AnalyticsSettings": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "enabled"
+ ]
+ },
+ "WhatsAppAnalytics": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "period": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "start": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "end": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ }
+ },
+ "required": [
+ "start",
+ "end"
+ ]
+ },
+ "requestVitals": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "requests": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "errorRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ }
+ },
+ "required": [
+ "requests",
+ "failures",
+ "errorRate"
+ ]
+ },
+ "summary": {
+ "type": "object",
+ "nullable": true,
+ "allOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "measured": {
+ "type": "boolean"
+ },
+ "observedHours": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "lastObservedAt": {
+ "type": "integer",
+ "description": "Unix milliseconds.",
+ "nullable": true
+ },
+ "outgoingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "incomingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendAttempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "businessReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageBusinessResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "onlineMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "disconnects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "connectFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "streamErrors": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "keepaliveTimeouts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "engagement": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "windowHours": {
+ "type": "integer",
+ "enum": [
+ 24
+ ]
+ },
+ "completedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveredConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "readConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "repliedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveryRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageCustomerReplyMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "complete": {
+ "type": "boolean"
+ },
+ "droppedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "droppedRecords": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "droppedReceiptJoins": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "windowHours",
+ "completedConversations",
+ "deliveredConversations",
+ "readConversations",
+ "repliedConversations",
+ "deliveryRate",
+ "readRate",
+ "replyRate",
+ "averageCustomerReplyMs",
+ "complete",
+ "droppedConversations",
+ "droppedRecords",
+ "droppedReceiptJoins"
+ ]
+ },
+ "totalNumbers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "measuredNumbers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "connectedNumbers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "messageAnalysis": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "complete": {
+ "type": "boolean"
+ },
+ "segments": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "dimension": {
+ "type": "string",
+ "enum": [
+ "message_type",
+ "text_band",
+ "origin",
+ "calling_code",
+ "customer_devices"
+ ]
+ },
+ "key": {
+ "type": "string"
+ },
+ "sendAttempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sent": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "completedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveredConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "readConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "repliedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveryRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageCustomerReplyMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shareOfConversations": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "shareOfSends": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRateInterval95": {
+ "type": "array",
+ "nullable": true,
+ "minItems": 2,
+ "maxItems": 2,
+ "items": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "description": "95% Wilson interval for observed conversation receipts or replies; not a causal effect."
+ },
+ "replyRateInterval95": {
+ "type": "array",
+ "nullable": true,
+ "minItems": 2,
+ "maxItems": 2,
+ "items": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "description": "95% Wilson interval for observed conversation receipts or replies; not a causal effect."
+ },
+ "readRateDifference": {
+ "type": "number",
+ "minimum": -1,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRateDifference": {
+ "type": "number",
+ "minimum": -1,
+ "maximum": 1,
+ "nullable": true
+ }
+ },
+ "required": [
+ "dimension",
+ "key",
+ "sendAttempts",
+ "sent",
+ "sendFailures",
+ "sendFailureRate",
+ "completedConversations",
+ "deliveredConversations",
+ "readConversations",
+ "repliedConversations",
+ "deliveryRate",
+ "readRate",
+ "replyRate",
+ "averageCustomerReplyMs",
+ "shareOfConversations",
+ "shareOfSends",
+ "readRateInterval95",
+ "replyRateInterval95",
+ "readRateDifference",
+ "replyRateDifference"
+ ]
+ }
+ }
+ },
+ "required": [
+ "complete",
+ "segments"
+ ]
+ },
+ "customerActivity": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "observed": {
+ "type": "boolean"
+ },
+ "onlineSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "offlineSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "typingSignals": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "observed",
+ "onlineSignals",
+ "offlineSignals",
+ "typingSignals"
+ ]
+ },
+ "accountActivity": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "observed": {
+ "type": "boolean"
+ },
+ "primaryPhoneActivitySignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "primaryPhoneActivePeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "completedPhoneActivityPeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneActivityMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePhoneActivityMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "phoneQuietGaps": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneQuietMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePhoneQuietMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "primaryPhoneMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "otherDeviceMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "lastPrimaryPhoneAt": {
+ "type": "integer",
+ "description": "Unix milliseconds.",
+ "nullable": true
+ },
+ "primaryPhoneReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "otherDeviceReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePrimaryPhoneResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageOtherDeviceResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "observed",
+ "primaryPhoneActivitySignals",
+ "primaryPhoneActivePeriods",
+ "completedPhoneActivityPeriods",
+ "phoneActivityMs",
+ "averagePhoneActivityMs",
+ "phoneQuietGaps",
+ "phoneQuietMs",
+ "averagePhoneQuietMs",
+ "primaryPhoneMessages",
+ "otherDeviceMessages",
+ "lastPrimaryPhoneAt",
+ "primaryPhoneReplies",
+ "otherDeviceReplies",
+ "averagePrimaryPhoneResponseMs",
+ "averageOtherDeviceResponseMs"
+ ]
+ },
+ "responseQueue": {
+ "type": "object",
+ "nullable": true,
+ "allOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "awaitingReply": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "oldestWaitingMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "observedAt": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "complete": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "awaitingReply",
+ "oldestWaitingMs",
+ "observedAt",
+ "complete"
+ ]
+ }
+ ]
+ },
+ "calls": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "directions": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "inbound": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video"
+ ]
+ },
+ "outbound": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video"
+ ]
+ }
+ },
+ "required": [
+ "inbound",
+ "outbound"
+ ]
+ },
+ "mediaQuality": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "measuredCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageJitterMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageRttMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "packetsLost": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "measuredCalls",
+ "averageJitterMs",
+ "averageRttMs",
+ "packetsLost"
+ ]
+ },
+ "appQuality": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "measuredCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageJitterMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageRttMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "packetLossRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "reconnects": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "measuredCalls",
+ "averageJitterMs",
+ "averageRttMs",
+ "packetLossRate",
+ "reconnects"
+ ]
+ },
+ "endReasons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "appErrors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "transports": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "multiParticipantCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "followUp": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "eligibleMissed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "returnedWithin24h": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "rate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageDelayMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "pendingWindow": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "unknownContact": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "eligibleMissed",
+ "returnedWithin24h",
+ "rate",
+ "averageDelayMs",
+ "pendingWindow",
+ "unknownContact"
+ ]
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video",
+ "directions",
+ "mediaQuality",
+ "appQuality",
+ "endReasons",
+ "appErrors",
+ "transports",
+ "multiParticipantCalls",
+ "followUp"
+ ]
+ }
+ },
+ "required": [
+ "measured",
+ "observedHours",
+ "lastObservedAt",
+ "outgoingMessages",
+ "incomingMessages",
+ "sendAttempts",
+ "sendFailures",
+ "sendFailureRate",
+ "businessReplies",
+ "averageBusinessResponseMs",
+ "onlineMs",
+ "disconnects",
+ "connectFailures",
+ "streamErrors",
+ "keepaliveTimeouts",
+ "engagement",
+ "totalNumbers",
+ "measuredNumbers",
+ "connectedNumbers",
+ "messageAnalysis",
+ "customerActivity",
+ "accountActivity",
+ "responseQueue",
+ "calls"
+ ]
+ }
+ ]
+ },
+ "numbers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "sessionId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "projectId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string"
+ },
+ "backend": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "measured": {
+ "type": "boolean"
+ },
+ "observedHours": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "lastObservedAt": {
+ "type": "integer",
+ "description": "Unix milliseconds.",
+ "nullable": true
+ },
+ "outgoingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "incomingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendAttempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "businessReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageBusinessResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "onlineMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "disconnects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "connectFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "streamErrors": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "keepaliveTimeouts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "engagement": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "windowHours": {
+ "type": "integer",
+ "enum": [
+ 24
+ ]
+ },
+ "completedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveredConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "readConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "repliedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveryRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageCustomerReplyMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "complete": {
+ "type": "boolean"
+ },
+ "droppedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "droppedRecords": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "droppedReceiptJoins": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "windowHours",
+ "completedConversations",
+ "deliveredConversations",
+ "readConversations",
+ "repliedConversations",
+ "deliveryRate",
+ "readRate",
+ "replyRate",
+ "averageCustomerReplyMs",
+ "complete",
+ "droppedConversations",
+ "droppedRecords",
+ "droppedReceiptJoins"
+ ]
+ },
+ "messageAnalysis": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "complete": {
+ "type": "boolean"
+ },
+ "segments": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "dimension": {
+ "type": "string",
+ "enum": [
+ "message_type",
+ "text_band",
+ "origin",
+ "calling_code",
+ "customer_devices"
+ ]
+ },
+ "key": {
+ "type": "string"
+ },
+ "sendAttempts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sent": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "completedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveredConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "readConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "repliedConversations": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "deliveryRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageCustomerReplyMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shareOfConversations": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "shareOfSends": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "readRateInterval95": {
+ "type": "array",
+ "nullable": true,
+ "minItems": 2,
+ "maxItems": 2,
+ "items": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "description": "95% Wilson interval for observed conversation receipts or replies; not a causal effect."
+ },
+ "replyRateInterval95": {
+ "type": "array",
+ "nullable": true,
+ "minItems": 2,
+ "maxItems": 2,
+ "items": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "description": "95% Wilson interval for observed conversation receipts or replies; not a causal effect."
+ },
+ "readRateDifference": {
+ "type": "number",
+ "minimum": -1,
+ "maximum": 1,
+ "nullable": true
+ },
+ "replyRateDifference": {
+ "type": "number",
+ "minimum": -1,
+ "maximum": 1,
+ "nullable": true
+ }
+ },
+ "required": [
+ "dimension",
+ "key",
+ "sendAttempts",
+ "sent",
+ "sendFailures",
+ "sendFailureRate",
+ "completedConversations",
+ "deliveredConversations",
+ "readConversations",
+ "repliedConversations",
+ "deliveryRate",
+ "readRate",
+ "replyRate",
+ "averageCustomerReplyMs",
+ "shareOfConversations",
+ "shareOfSends",
+ "readRateInterval95",
+ "replyRateInterval95",
+ "readRateDifference",
+ "replyRateDifference"
+ ]
+ }
+ }
+ },
+ "required": [
+ "complete",
+ "segments"
+ ]
+ },
+ "customerActivity": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "observed": {
+ "type": "boolean"
+ },
+ "onlineSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "offlineSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "typingSignals": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "observed",
+ "onlineSignals",
+ "offlineSignals",
+ "typingSignals"
+ ]
+ },
+ "accountActivity": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "observed": {
+ "type": "boolean"
+ },
+ "primaryPhoneActivitySignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "primaryPhoneActivePeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "completedPhoneActivityPeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneActivityMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePhoneActivityMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "phoneQuietGaps": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneQuietMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePhoneQuietMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "primaryPhoneMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "otherDeviceMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "lastPrimaryPhoneAt": {
+ "type": "integer",
+ "description": "Unix milliseconds.",
+ "nullable": true
+ },
+ "primaryPhoneReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "otherDeviceReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averagePrimaryPhoneResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageOtherDeviceResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "observed",
+ "primaryPhoneActivitySignals",
+ "primaryPhoneActivePeriods",
+ "completedPhoneActivityPeriods",
+ "phoneActivityMs",
+ "averagePhoneActivityMs",
+ "phoneQuietGaps",
+ "phoneQuietMs",
+ "averagePhoneQuietMs",
+ "primaryPhoneMessages",
+ "otherDeviceMessages",
+ "lastPrimaryPhoneAt",
+ "primaryPhoneReplies",
+ "otherDeviceReplies",
+ "averagePrimaryPhoneResponseMs",
+ "averageOtherDeviceResponseMs"
+ ]
+ },
+ "responseQueue": {
+ "type": "object",
+ "nullable": true,
+ "allOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "awaitingReply": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "oldestWaitingMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "observedAt": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "complete": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "awaitingReply",
+ "oldestWaitingMs",
+ "observedAt",
+ "complete"
+ ]
+ }
+ ]
+ },
+ "projectName": {
+ "type": "string"
+ },
+ "calls": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "directions": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "inbound": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video"
+ ]
+ },
+ "outbound": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video"
+ ]
+ }
+ },
+ "required": [
+ "inbound",
+ "outbound"
+ ]
+ },
+ "mediaQuality": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "measuredCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageJitterMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageRttMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "packetsLost": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "measuredCalls",
+ "averageJitterMs",
+ "averageRttMs",
+ "packetsLost"
+ ]
+ },
+ "appQuality": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "measuredCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageJitterMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averageRttMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "packetLossRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "reconnects": {
+ "type": "integer",
+ "minimum": 0,
+ "nullable": true
+ }
+ },
+ "required": [
+ "measuredCalls",
+ "averageJitterMs",
+ "averageRttMs",
+ "packetLossRate",
+ "reconnects"
+ ]
+ },
+ "endReasons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "appErrors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "transports": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "code",
+ "count"
+ ]
+ }
+ },
+ "multiParticipantCalls": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "followUp": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "eligibleMissed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "returnedWithin24h": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "rate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "averageDelayMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "pendingWindow": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "unknownContact": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "eligibleMissed",
+ "returnedWithin24h",
+ "rate",
+ "averageDelayMs",
+ "pendingWindow",
+ "unknownContact"
+ ]
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video",
+ "directions",
+ "mediaQuality",
+ "appQuality",
+ "endReasons",
+ "appErrors",
+ "transports",
+ "multiParticipantCalls",
+ "followUp"
+ ]
+ }
+ },
+ "required": [
+ "sessionId",
+ "projectId",
+ "name",
+ "backend",
+ "status",
+ "measured",
+ "observedHours",
+ "lastObservedAt",
+ "outgoingMessages",
+ "incomingMessages",
+ "sendAttempts",
+ "sendFailures",
+ "sendFailureRate",
+ "businessReplies",
+ "averageBusinessResponseMs",
+ "onlineMs",
+ "disconnects",
+ "connectFailures",
+ "streamErrors",
+ "keepaliveTimeouts",
+ "engagement",
+ "messageAnalysis",
+ "customerActivity",
+ "accountActivity",
+ "responseQueue",
+ "projectName",
+ "calls"
+ ]
+ }
+ },
+ "series": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "ts": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ },
+ "outgoingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "incomingMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sendFailures": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "businessReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageBusinessResponseMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "onlineMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "disconnects": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sessionId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "customerOnlineSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "customerTypingSignals": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "primaryPhoneMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "otherDeviceMessages": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "primaryPhoneReplies": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneActivePeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "completedPhoneActivityPeriods": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneActivityMs": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneQuietGaps": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "phoneQuietMs": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "ts",
+ "outgoingMessages",
+ "incomingMessages",
+ "sendFailures",
+ "businessReplies",
+ "averageBusinessResponseMs",
+ "onlineMs",
+ "disconnects",
+ "sessionId",
+ "customerOnlineSignals",
+ "customerTypingSignals",
+ "primaryPhoneMessages",
+ "otherDeviceMessages",
+ "primaryPhoneReplies",
+ "phoneActivePeriods",
+ "completedPhoneActivityPeriods",
+ "phoneActivityMs",
+ "phoneQuietGaps",
+ "phoneQuietMs"
+ ]
+ }
+ },
+ "callSeries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "missed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "declined": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "failed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "ringing": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "answerRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "nullable": true
+ },
+ "talkSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "timedPickup": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "averageTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "medianTalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95TalkSeconds": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "averagePickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "p95PickupMs": {
+ "type": "number",
+ "minimum": 0,
+ "nullable": true
+ },
+ "shortAnswered": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "video": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "sessionId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "ts": {
+ "type": "integer",
+ "description": "Unix milliseconds."
+ }
+ },
+ "required": [
+ "total",
+ "answered",
+ "missed",
+ "declined",
+ "failed",
+ "ringing",
+ "answerRate",
+ "talkSeconds",
+ "timedAnswered",
+ "timedPickup",
+ "averageTalkSeconds",
+ "medianTalkSeconds",
+ "p95TalkSeconds",
+ "averagePickupMs",
+ "p95PickupMs",
+ "shortAnswered",
+ "video",
+ "sessionId",
+ "ts"
+ ]
+ }
+ }
+ },
+ "required": [
+ "enabled",
+ "period",
+ "requestVitals",
+ "summary",
+ "numbers",
+ "series",
+ "callSeries"
+ ]
+ },
"DataEnvelope": {
"type": "object",
"properties": {
diff --git a/contracts/source.json b/contracts/source.json
index 4488715a..d6a23c2f 100644
--- a/contracts/source.json
+++ b/contracts/source.json
@@ -1,9 +1,9 @@
{
"repository": "polymorfa/polymorfa",
- "commit": "ac1c7010d5c3cdf0262f1b5deb2c4d7ce6a249a5",
- "branch": "t3code/project-billing-dashboard",
- "published": false,
- "note": "Exact Messaging and Platform snapshots from the Usage and Logs stack. Financial operations require organization billing scopes. Private Console operations remain excluded from SDK credentials. API deployment and SDK publication are separate gates.",
+ "commit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
+ "branch": "dev",
+ "published": true,
+ "note": "Full Messaging and Platform snapshots pin the merged API dev revision. SDK publication and API deployment remain separate gates.",
"contracts": {
"messaging": {
"sourcePath": "apps/api/docs/openapi.json",
@@ -13,7 +13,7 @@
"platform": {
"sourcePath": "apps/api/docs/openapi.management.json",
"snapshotPath": "contracts/openapi.platform.json",
- "sha256": "7f29c5c4a3d309a127e34fcee8dfcedada572141ab5c5902886abe3c4dcf8a7c"
+ "sha256": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8"
}
}
}
diff --git a/packages/typescript/README.md b/packages/typescript/README.md
index a74f40dd..27045914 100644
--- a/packages/typescript/README.md
+++ b/packages/typescript/README.md
@@ -2869,6 +2869,49 @@ occurred; reconcile it before choosing a new key. Request/response bodies and
customer log output are not retained. List responses contain `items` and
`nextCursor`; pass that cursor as `before` to read the next page.
+## WhatsApp business analytics
+
+Source builds expose `client.analytics.get({ projectId, sessionId, start, end })`.
+Requires `sessions:read`; project-bound clients cannot read another project.
+A team owner or admin enables Analytics in the Console. Disabled results contain
+no business aggregates.
+
+```ts
+const { data } = await client.analytics.get({ sessionId: numberId });
+console.log(data.summary?.engagement.replyRate);
+console.log(data.summary?.calls.answerRate);
+console.log(data.summary?.calls.followUp);
+console.log(data.callSeries);
+```
+
+Messaging results include completed 24-hour receipt/reply cohorts, response
+queues, coarse metadata comparisons and observed phone/customer activity.
+Calls include outcomes, direction breakdowns, measured pickup and length timings,
+reported quality, failure categories and completed missed-call follow-up windows.
+Call results do not require messaging telemetry. Preserve `null` measurements;
+missing reports do not imply healthy quality. Disabling Analytics clears message
+and engagement aggregates and hides the call view; operational call history is
+retained separately. [Measurement definitions](https://docs.polymorfa.com/console/analytics).
+These source-build methods have not been published in a package release.
+
+## Analytics metrics export
+
+Source builds expose `client.analytics.metrics` on root and project clients:
+
+```ts
+const { data, metadata } = await client.analytics.metrics({
+ windowHours: 24,
+ format: "openmetrics",
+ segments: true,
+});
+```
+
+`data` is the metrics text; `metadata` retains HTTP status, request ID and headers.
+The method validates UUID filters and the 1–168-hour window. It uses `sessions:read`
+and the same Analytics opt-in. All metrics are gauges over completed UTC hours;
+never apply `rate()` or `increase()`. Disabled exports contain enablement and window
+metadata only. [Collector setup and definitions](https://docs.polymorfa.com/console/analytics-collectors).
+
## Spending limits and funding order
```ts
diff --git a/packages/typescript/src/client.ts b/packages/typescript/src/client.ts
index 2422f447..eff8aad9 100644
--- a/packages/typescript/src/client.ts
+++ b/packages/typescript/src/client.ts
@@ -1,3 +1,4 @@
+import { AnalyticsResource } from "./platform/analytics.js";
import { FlowsResource } from "./platform/flows.js";
import { FunctionsResource } from "./platform/functions.js";
import { SessionConfigurationResource } from "./platform/session-configuration.js";
@@ -71,6 +72,7 @@ export interface ClientBase {
readonly voice: VoiceResource;
/** Metered usage and usage gates. */
readonly usage: UsageResource;
+ readonly analytics: AnalyticsResource;
readonly calls: PlatformCallsResource;
readonly callRetention: CallRetentionResource;
readonly raw: RawResourceFor;
@@ -130,6 +132,7 @@ class ClientImplementation implements ClientBase {
readonly sipTrunks: SipTrunksResource;
readonly voice: VoiceResource;
readonly usage: UsageResource;
+ readonly analytics: AnalyticsResource;
readonly calls: PlatformCallsResource;
readonly callRetention: CallRetentionResource;
readonly raw: RawClient | ProjectScopedRawClient;
@@ -194,6 +197,7 @@ class ClientImplementation implements ClientBase {
projectId !== null && credential.type !== "projectToken",
);
this.usage = new UsageResource(this.#transport, projectId);
+ this.analytics = new AnalyticsResource(this.#transport, projectId);
this.calls = new PlatformCallsResource(this.#transport, projectId);
this.callRetention = new CallRetentionResource(this.#transport);
this.raw =
diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts
index deed869d..20218c32 100644
--- a/packages/typescript/src/index.ts
+++ b/packages/typescript/src/index.ts
@@ -1242,6 +1242,17 @@ export {
type FlowProviderResult,
} from "./platform/flows.js";
+export { AnalyticsResource } from "./platform/analytics.js";
+export type {
+ CallOutcomeMetrics,
+ CallBusinessMetrics,
+ AnalyticsParams,
+ AnalyticsMetricsParams,
+ WhatsAppBusinessSegment,
+ WhatsAppAnalytics,
+ WhatsAppMetrics,
+ WhatsAppEngagement,
+} from "./platform/analytics.js";
export type {
ResourceBillingControls,
BillingScope,
diff --git a/packages/typescript/src/platform/analytics.ts b/packages/typescript/src/platform/analytics.ts
new file mode 100644
index 00000000..3bd5dc0b
--- /dev/null
+++ b/packages/typescript/src/platform/analytics.ts
@@ -0,0 +1,363 @@
+// Platform analytics contract: polymorfa/polymorfa@171a9683ff45d11a4b5d3917dc428ad2722b0d99
+import { PolymorfaValidationError, PolymorfaServerError } from "../errors.js";
+import { HttpTransport } from "../transport/http.js";
+import type {
+ ApiResponse,
+ QueryValue,
+ RequestOptions,
+} from "../transport/types.js";
+import { type DataEnvelope, unwrapResponse } from "./response.js";
+export interface CallOutcomeMetrics {
+ total: number;
+ answered: number;
+ missed: number;
+ declined: number;
+ failed: number;
+ ringing: number;
+ answerRate: number | null;
+ talkSeconds: number;
+ timedAnswered: number;
+ timedPickup: number;
+ averageTalkSeconds: number | null;
+ medianTalkSeconds: number | null;
+ p95TalkSeconds: number | null;
+ averagePickupMs: number | null;
+ p95PickupMs: number | null;
+ shortAnswered: number;
+ video: number;
+}
+export interface CallBusinessMetrics extends CallOutcomeMetrics {
+ directions: { inbound: CallOutcomeMetrics; outbound: CallOutcomeMetrics };
+ mediaQuality: {
+ measuredCalls: number;
+ averageJitterMs: number | null;
+ averageRttMs: number | null;
+ packetsLost: number | null;
+ };
+ appQuality: {
+ measuredCalls: number;
+ averageJitterMs: number | null;
+ averageRttMs: number | null;
+ packetLossRate: number | null;
+ reconnects: number | null;
+ };
+ endReasons: { code: string; count: number }[];
+ appErrors: { code: string; count: number }[];
+ transports: { code: string; count: number }[];
+ multiParticipantCalls: number;
+ followUp: {
+ eligibleMissed: number;
+ returnedWithin24h: number;
+ rate: number | null;
+ averageDelayMs: number | null;
+ pendingWindow: number;
+ unknownContact: number;
+ };
+}
+export interface WhatsAppBusinessSegment {
+ dimension:
+ | "message_type"
+ | "text_band"
+ | "origin"
+ | "calling_code"
+ | "customer_devices";
+ key: string;
+ sendAttempts: number;
+ sent: number;
+ sendFailures: number;
+ sendFailureRate: number | null;
+ completedConversations: number;
+ deliveredConversations: number;
+ readConversations: number;
+ repliedConversations: number;
+ deliveryRate: number | null;
+ readRate: number | null;
+ replyRate: number | null;
+ averageCustomerReplyMs: number | null;
+ readRateInterval95: [number, number] | null;
+ replyRateInterval95: [number, number] | null;
+ readRateDifference: number | null;
+ replyRateDifference: number | null;
+ shareOfConversations: number | null;
+ shareOfSends: number | null;
+}
+export interface WhatsAppEngagement {
+ windowHours: 24;
+ completedConversations: number;
+ deliveredConversations: number;
+ readConversations: number;
+ repliedConversations: number;
+ deliveryRate: number | null;
+ readRate: number | null;
+ replyRate: number | null;
+ averageCustomerReplyMs: number | null;
+ complete: boolean;
+ droppedConversations: number;
+ droppedRecords: number;
+ droppedReceiptJoins: number;
+}
+export interface WhatsAppMetrics {
+ calls: CallBusinessMetrics;
+ measured: boolean;
+ observedHours: number;
+ lastObservedAt: number | null;
+ outgoingMessages: number;
+ incomingMessages: number;
+ sendAttempts: number;
+ sendFailures: number;
+ sendFailureRate: number | null;
+ businessReplies: number;
+ averageBusinessResponseMs: number | null;
+ onlineMs: number;
+ disconnects: number;
+ connectFailures: number;
+ streamErrors: number;
+ keepaliveTimeouts: number;
+ engagement: WhatsAppEngagement;
+ messageAnalysis: { complete: boolean; segments: WhatsAppBusinessSegment[] };
+ customerActivity: {
+ observed: boolean;
+ onlineSignals: number;
+ offlineSignals: number;
+ typingSignals: number;
+ };
+ accountActivity: {
+ observed: boolean;
+ primaryPhoneActivitySignals: number;
+ primaryPhoneActivePeriods: number;
+ completedPhoneActivityPeriods: number;
+ phoneActivityMs: number;
+ averagePhoneActivityMs: number | null;
+ phoneQuietGaps: number;
+ phoneQuietMs: number;
+ averagePhoneQuietMs: number | null;
+ primaryPhoneMessages: number;
+ otherDeviceMessages: number;
+ primaryPhoneReplies: number;
+ otherDeviceReplies: number;
+ averagePrimaryPhoneResponseMs: number | null;
+ averageOtherDeviceResponseMs: number | null;
+ lastPrimaryPhoneAt: number | null;
+ };
+ responseQueue: {
+ awaitingReply: number;
+ oldestWaitingMs: number;
+ observedAt: number;
+ complete: boolean;
+ } | null;
+}
+export interface WhatsAppAnalytics {
+ callSeries: (CallOutcomeMetrics & { sessionId: string; ts: number })[];
+ enabled: boolean;
+ period: { start: number; end: number };
+ requestVitals: {
+ requests: number;
+ failures: number;
+ errorRate: number | null;
+ };
+ summary:
+ | (WhatsAppMetrics & {
+ totalNumbers: number;
+ measuredNumbers: number;
+ connectedNumbers: number;
+ })
+ | null;
+ numbers: (WhatsAppMetrics & {
+ sessionId: string;
+ projectId: string;
+ projectName: string;
+ name: string;
+ backend: string;
+ status: string;
+ })[];
+ series: {
+ customerOnlineSignals: number;
+ customerTypingSignals: number;
+ primaryPhoneMessages: number;
+ otherDeviceMessages: number;
+ primaryPhoneReplies: number;
+ phoneActivePeriods: number;
+ completedPhoneActivityPeriods: number;
+ phoneActivityMs: number;
+ phoneQuietGaps: number;
+ phoneQuietMs: number;
+ sessionId: string;
+ ts: number;
+ outgoingMessages: number;
+ incomingMessages: number;
+ sendFailures: number;
+ businessReplies: number;
+ averageBusinessResponseMs: number | null;
+ onlineMs: number;
+ disconnects: number;
+ }[];
+}
+export interface AnalyticsSettings {
+ enabled: boolean;
+}
+
+export interface AnalyticsParams {
+ readonly projectId?: string;
+ readonly sessionId?: string;
+ /** Unix milliseconds; defaults to the preceding 30 days. Maximum range 366 days. */
+ readonly start?: number;
+ readonly end?: number;
+}
+export interface AnalyticsMetricsParams {
+ readonly projectId?: string;
+ readonly sessionId?: string;
+ /** Completed UTC hours, 1-168. Default 24. */
+ readonly windowHours?: number;
+ /** Include bounded metadata breakdowns. Default false. */
+ readonly segments?: boolean;
+ readonly format?: "prometheus" | "openmetrics";
+}
+/** Message aggregates from Linked Devices telemetry and retained call outcomes. Requires sessions:read.
+ * Owners/admins enable Analytics in the Console. Disabled results contain no
+ * business data. Read/reply percentages are completed 24-hour direct-chat
+ * cohorts; null means there is no denominator or coverage is incomplete. */
+export class AnalyticsResource {
+ constructor(
+ private readonly transport: HttpTransport,
+ private readonly projectId: string | null,
+ ) {}
+ /** Collector-compatible windowed gauges. Never apply rate() or increase().
+ * Disabled Analytics returns enablement and window metadata only. */
+ async metrics(
+ params: AnalyticsMetricsParams = {},
+ options: RequestOptions = {},
+ ): Promise> {
+ for (const key of ["projectId", "sessionId"] as const) {
+ if (
+ params[key] !== undefined &&
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ params[key],
+ )
+ )
+ throw new PolymorfaValidationError(`${key} must be a UUID.`, {
+ code: "invalid_analytics_filter",
+ });
+ }
+ if (
+ this.projectId !== null &&
+ params.projectId !== undefined &&
+ params.projectId.toLowerCase() !== this.projectId.toLowerCase()
+ )
+ throw new PolymorfaValidationError(
+ "Analytics cannot read outside the client's project.",
+ { code: "invalid_analytics_filter" },
+ );
+ if (
+ params.windowHours !== undefined &&
+ (!Number.isInteger(params.windowHours) ||
+ params.windowHours < 1 ||
+ params.windowHours > 168)
+ )
+ throw new PolymorfaValidationError(
+ "windowHours must be an integer from 1 to 168.",
+ { code: "invalid_analytics_range" },
+ );
+ if (params.segments !== undefined && typeof params.segments !== "boolean")
+ throw new PolymorfaValidationError("segments must be a boolean.", {
+ code: "invalid_analytics_filter",
+ });
+ const format = params.format ?? "prometheus";
+ if (format !== "prometheus" && format !== "openmetrics")
+ throw new PolymorfaValidationError(
+ "format must be prometheus or openmetrics.",
+ { code: "invalid_analytics_filter" },
+ );
+ const query: Record = { ...params, format };
+ if (this.projectId !== null) delete query.projectId;
+ const response = await this.transport.requestText(
+ {
+ ...options,
+ method: "GET",
+ path:
+ this.projectId === null
+ ? "/platform/analytics/metrics"
+ : `/platform/projects/${encodeURIComponent(this.projectId)}/analytics/metrics`,
+ query,
+ },
+ format === "openmetrics" ? "application/openmetrics-text" : "text/plain",
+ );
+ const expected =
+ format === "openmetrics" ? "application/openmetrics-text" : "text/plain";
+ if (
+ !/^# TYPE polymorfa_analytics_enabled gauge$/m.test(response.data) ||
+ response.metadata.headers["content-type"]
+ ?.split(";", 1)[0]
+ ?.trim()
+ .toLowerCase() !== expected ||
+ (format === "openmetrics" && !response.data.endsWith("# EOF\n"))
+ )
+ throw new PolymorfaServerError(
+ "The API returned an invalid analytics metrics response.",
+ {
+ code: "invalid_response",
+ status: response.metadata.status,
+ metadata: response.metadata,
+ },
+ );
+ return response;
+ }
+ async get(
+ params: AnalyticsParams = {},
+ options: RequestOptions = {},
+ ): Promise> {
+ for (const key of ["projectId", "sessionId"] as const) {
+ const value = params[key];
+ if (
+ value !== undefined &&
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ value,
+ )
+ )
+ throw new PolymorfaValidationError(`${key} must be a UUID.`, {
+ code: "invalid_analytics_filter",
+ });
+ }
+ if (
+ this.projectId !== null &&
+ params.projectId !== undefined &&
+ params.projectId.toLowerCase() !== this.projectId.toLowerCase()
+ )
+ throw new PolymorfaValidationError(
+ "Analytics cannot read outside the client's project.",
+ { code: "invalid_analytics_filter" },
+ );
+ for (const key of ["start", "end"] as const) {
+ const value = params[key];
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
+ throw new PolymorfaValidationError(
+ `${key} must be Unix milliseconds.`,
+ { code: "invalid_analytics_range" },
+ );
+ }
+ if (
+ params.start !== undefined &&
+ params.end !== undefined &&
+ (params.end < params.start ||
+ params.end - params.start > 366 * 86_400_000)
+ )
+ throw new PolymorfaValidationError(
+ "Choose an ordered analytics range of up to 366 days.",
+ { code: "invalid_analytics_range" },
+ );
+ const query: Record = {};
+ for (const [key, value] of Object.entries(params))
+ if (value !== undefined) query[key] = value;
+ if (this.projectId !== null) delete query.projectId;
+ return unwrapResponse(
+ await this.transport.request>({
+ ...options,
+ method: "GET",
+ path:
+ this.projectId === null
+ ? "/platform/analytics"
+ : `/platform/projects/${encodeURIComponent(this.projectId)}/analytics`,
+ query,
+ }),
+ );
+ }
+}
diff --git a/packages/typescript/test/analytics.test.ts b/packages/typescript/test/analytics.test.ts
new file mode 100644
index 00000000..5947cbd9
--- /dev/null
+++ b/packages/typescript/test/analytics.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, expect, it } from "vitest";
+import { Client, PolymorfaValidationError } from "../src/index.js";
+import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js";
+import { startTestServer, type TestServer } from "./support/http-server.js";
+const servers: TestServer[] = [];
+afterEach(async () => Promise.all(servers.splice(0).map((s) => s.close())));
+it("reads enabled or disabled analytics and confines a project token without widening", async () => {
+ const body = {
+ enabled: false,
+ period: { start: 0, end: 1 },
+ requestVitals: { requests: 0, failures: 0, errorRate: null },
+ summary: null,
+ numbers: [],
+ series: [],
+ callSeries: [],
+ };
+ const server = await startTestServer(() => ({
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ data: body }),
+ }));
+ servers.push(server);
+ const root = new Client({
+ credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY },
+ baseUrl: server.url,
+ });
+ expect((await root.analytics.get({ start: 0, end: 1 })).data).toEqual(body);
+ const id = "111111ab-2222-4333-8444-555555555555";
+ const project = new Client({
+ credential: { type: "projectToken", value: PROJECT_TOKEN },
+ projectId: id,
+ baseUrl: server.url,
+ });
+ expect((await project.analytics.get({ sessionId: id })).data.enabled).toBe(
+ false,
+ );
+ expect(server.requests[1]?.path).toBe(
+ `/platform/projects/${id}/analytics?sessionId=${id}`,
+ );
+ expect(server.requests[1]?.headers.authorization).toContain(PROJECT_TOKEN);
+ expect(
+ (await project.analytics.get({ projectId: id.toUpperCase() })).data.enabled,
+ ).toBe(false);
+ expect(server.requests[2]?.path).toBe(`/platform/projects/${id}/analytics`);
+ await expect(
+ project.analytics.get({
+ projectId: "22222222-2222-4333-8444-555555555555",
+ }),
+ ).rejects.toBeInstanceOf(PolymorfaValidationError);
+ await expect(
+ root.analytics.get({ start: 10, end: 1 }),
+ ).rejects.toBeInstanceOf(PolymorfaValidationError);
+ await expect(
+ root.analytics.get({ sessionId: "name" }),
+ ).rejects.toBeInstanceOf(PolymorfaValidationError);
+});
+
+it("preserves nullable call quality, follow-up coverage and per-number call series", async () => {
+ const calls = {
+ total: 3,
+ answerRate: 0.5,
+ mediaQuality: { measuredCalls: 0, averageRttMs: null },
+ followUp: {
+ eligibleMissed: 1,
+ returnedWithin24h: 0,
+ rate: 0,
+ pendingWindow: 1,
+ unknownContact: 1,
+ },
+ };
+ const accountActivity = {
+ completedPhoneActivityPeriods: 2,
+ phoneActivityMs: 600_000,
+ averagePhoneActivityMs: 300_000,
+ phoneQuietGaps: 1,
+ phoneQuietMs: 1_200_000,
+ averagePhoneQuietMs: 1_200_000,
+ };
+ const body = {
+ enabled: true,
+ summary: { calls, accountActivity },
+ numbers: [{ sessionId: "number", calls }],
+ series: [],
+ callSeries: [{ sessionId: "number", ts: 1, answered: 1, missed: 1 }],
+ };
+ const server = await startTestServer(() => ({
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ data: body }),
+ }));
+ servers.push(server);
+ const client = new Client({
+ credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY },
+ baseUrl: server.url,
+ });
+ const result = (await client.analytics.get()).data;
+ expect(result.summary?.accountActivity).toEqual(accountActivity);
+ expect(result.summary?.calls.followUp).toEqual(calls.followUp);
+ expect(result.numbers[0]?.calls.mediaQuality.averageRttMs).toBeNull();
+ expect(result.callSeries).toEqual(body.callSeries);
+});
+
+it("returns collector text and metadata with validated project filters", async () => {
+ const id = "11111111-2222-4333-8444-555555555555";
+ const body =
+ '# HELP polymorfa_analytics_enabled Analytics enablement.\n# TYPE polymorfa_analytics_enabled gauge\npolymorfa_analytics_enabled{scope="project"} 0\n# EOF\n';
+ const server = await startTestServer(() => ({
+ headers: {
+ "content-type":
+ "application/openmetrics-text; version=1.0.0; charset=utf-8",
+ "x-request-id": "metrics-1",
+ },
+ body,
+ }));
+ servers.push(server);
+ const client = new Client({
+ credential: { type: "projectToken", value: PROJECT_TOKEN },
+ projectId: id,
+ baseUrl: server.url,
+ });
+ const response = await client.analytics.metrics({
+ windowHours: 6,
+ segments: true,
+ format: "openmetrics",
+ sessionId: id,
+ });
+ expect(response.data).toBe(body);
+ expect(response.metadata.requestId).toBe("metrics-1");
+ expect(server.requests[0]?.path).toContain(
+ `/platform/projects/${id}/analytics/metrics?`,
+ );
+ expect(server.requests[0]?.path).toContain("segments=true");
+ expect(server.requests[0]?.headers.accept).toBe(
+ "application/openmetrics-text",
+ );
+ for (const params of [
+ { windowHours: 0 },
+ { windowHours: 169 },
+ { windowHours: 1.5 },
+ { sessionId: "phone" },
+ { projectId: "22222222-2222-4333-8444-555555555555" },
+ ])
+ await expect(client.analytics.metrics(params)).rejects.toBeInstanceOf(
+ PolymorfaValidationError,
+ );
+ expect(server.requests).toHaveLength(1);
+});
+
+it("rejects HTML success bodies and incomplete OpenMetrics framing", async () => {
+ for (const [contentType, body] of [
+ ["text/html", "Proxy failure
"],
+ ["application/openmetrics-text", "metric 1\n"],
+ ]) {
+ const server = await startTestServer(() => ({
+ headers: { "content-type": contentType! },
+ body: body!,
+ }));
+ servers.push(server);
+ const client = new Client({
+ credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY },
+ baseUrl: server.url,
+ });
+ await expect(
+ client.analytics.metrics({ format: "openmetrics" }),
+ ).rejects.toMatchObject({ code: "invalid_response" });
+ }
+});
diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts
index de3fe0df..fbb83a13 100644
--- a/packages/typescript/test/coverage-reconciliation.test.ts
+++ b/packages/typescript/test/coverage-reconciliation.test.ts
@@ -10,6 +10,7 @@ type LedgerEntry = {
method: string;
path: string;
operationId: string;
+ sourceCommit?: string;
typescript: { status: string; method?: string; reason?: string };
};
@@ -186,7 +187,7 @@ const calls = [
] as const;
describe("reconciled coverage evidence", () => {
- it("pins byte-identical source snapshots and the ledger to one revision", () => {
+ it("pins reviewed snapshot hashes and the ledger baseline revision", () => {
const source = JSON.parse(
readFileSync(repositoryFile("contracts/source.json"), "utf8"),
) as {
@@ -196,7 +197,7 @@ describe("reconciled coverage evidence", () => {
};
expect(source.repository).toBe("polymorfa/polymorfa");
// Repinning the reviewed source requires updating this regression gate too.
- expect(source.commit).toBe("ac1c7010d5c3cdf0262f1b5deb2c4d7ce6a249a5");
+ expect(source.commit).toBe("171a9683ff45d11a4b5d3917dc428ad2722b0d99");
expect(ledger.sourceCommit).toBe(source.commit);
expect(Object.keys(source.contracts).sort()).toEqual([
"messaging",
@@ -210,6 +211,38 @@ describe("reconciled coverage evidence", () => {
}
});
+ it("pins Analytics to the same full Platform source and snapshot hash", () => {
+ const source = JSON.parse(
+ readFileSync(repositoryFile("contracts/source.json"), "utf8"),
+ ) as {
+ commit: string;
+ contracts: { platform: { sha256: string } };
+ supplements?: unknown;
+ };
+ expect(source.supplements).toBeUndefined();
+ expect(source.contracts.platform.sha256).toBe(
+ "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8",
+ );
+ for (const id of [
+ "getWhatsAppAnalytics",
+ "getProjectWhatsAppAnalytics",
+ "exportWhatsAppAnalyticsMetrics",
+ "exportProjectWhatsAppAnalyticsMetrics",
+ ]) {
+ const operation = entry(id) as LedgerEntry & {
+ sourceContractSha256: string;
+ sdkContractSha256: string;
+ };
+ expect(operation.sourceCommit).toBe(source.commit);
+ expect(operation.sourceContractSha256).toBe(
+ source.contracts.platform.sha256,
+ );
+ expect(operation.sdkContractSha256).toBe(
+ source.contracts.platform.sha256,
+ );
+ }
+ });
+
it("maps all four hosted-history reads to the server SDK", () => {
expect(entry("listChats").typescript).toEqual({
status: "covered",
diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts
index 2d03f318..4be64f59 100644
--- a/packages/typescript/test/coverage.test.ts
+++ b/packages/typescript/test/coverage.test.ts
@@ -173,12 +173,12 @@ describe("coverage checker", () => {
const result = runRepositoryChecker();
expect(result.status, result.stderr).toBe(0);
expect(result.report).toMatchObject({
- sourceCommit: "ac1c7010d5c3cdf0262f1b5deb2c4d7ce6a249a5",
- total: 574,
- covered: 429,
+ sourceCommit: "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
+ total: 579,
+ covered: 433,
partial: 0,
missing: 0,
- excluded: 145,
+ excluded: 146,
changed: 0,
});
const resolutions = (result.report?.resolutions ?? []) as Array<{