diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f7e8440..13430e3c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ jobs:
- run: npm run typecheck
- run: npm run build:workspaces
- run: npm test
- - run: node --test scripts/release-gate.test.mjs scripts/native-release.test.mjs scripts/native-publish.test.mjs scripts/sync-linear-coverage.test.mjs
+ - run: node --test scripts/release-gate.test.mjs scripts/native-release.test.mjs scripts/native-publish.test.mjs scripts/sync-linear-coverage.test.mjs scripts/passkeys-candidate.test.mjs
- run: node --test scripts/sdk-fixture-server.test.mjs scripts/multi-language-coverage.test.mjs examples/calls-example/server-credential.test.mjs
- run: python3 scripts/inspect-native-artifacts.test.py
- run: python3 scripts/prepare-php-mirror.test.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3e1dea4..cef7379f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+- Prepare direct server passkey challenge, assertion and explicit confirmation methods in all six SDKs. Writes use one attempt; ceremony execution remains external. Bound to accepted API source `77e9ce31`; unreleased until package publication.
+
+- Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Bound to accepted API source `77e9ce31`, which also clarifies when a ban is excluded; unreleased.
+
+- TypeScript, Python and PHP call-settings updates now refuse keys the API does not accept, including the response-only `simultaneousCallsCoverageExcludedUntil`, before sending. Go, Rust and .NET requests already serialize only writable fields.
+
- All six server SDKs expose optional automatic-pause `reason`/`code` and
optional recipient-skip `messageIndex`/`followUpId` from accepted API source
`9bb1e2ed`. Future reason/code strings and raw envelope fields are retained;
diff --git a/contracts/calls-coverage-end.candidate.fragments.json b/contracts/calls-coverage-end.candidate.fragments.json
new file mode 100644
index 00000000..a9b5a92f
--- /dev/null
+++ b/contracts/calls-coverage-end.candidate.fragments.json
@@ -0,0 +1,611 @@
+{
+ "schemas": {
+ "PlatformAccessCallSettings": {
+ "type": "object",
+ "properties": {
+ "callsEnabled": {
+ "type": "boolean",
+ "description": "Whether the session can place, answer and receive calls. When false, placing, answering, joining and attaching media fail with `403 calls_disabled`, incoming calls are declined, and SIP trunks cannot place calls through the session. Calls in progress continue, and the participant that answered or placed one can reconnect to it. Defaults to true."
+ },
+ "conferenceMode": {
+ "type": "boolean",
+ "description": "Conference mode. When true, every participant you connect to a call (including SIP trunk callers) hears the WhatsApp party and every other participant you connected. When false, each of them hears only the WhatsApp party. The WhatsApp party always hears all of your participants, and no participant hears their own audio. Defaults to true."
+ },
+ "inboundRoute": {
+ "$ref": "#/components/schemas/PlatformAccessCallInboundRoute"
+ },
+ "sipTrunkId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid",
+ "description": "The SIP trunk that receives incoming calls when `inboundRoute` is `sip_trunk`; otherwise null."
+ },
+ "sipClaim": {
+ "type": "boolean",
+ "description": "Whether an answer from the SIP trunk claims the call, which ends ringing on your other clients. Defaults to true."
+ },
+ "hostCloudApiCalls": {
+ "type": "boolean",
+ "description": "Whether Polymorfa Calls answers the session's incoming calls when the session uses the Cloud API. When false (the default), your app answers them through the Graph API `/calls` endpoint and Polymorfa Calls does not host them. Sessions on a linked device ignore it."
+ },
+ "simultaneousCalls": {
+ "type": "boolean",
+ "description": "Whether this Number may hold more than one call at a time, with no limit. Linked-device sessions only; Cloud API sessions ignore it. WhatsApp's own apps never hold two calls at once, so this behavior is unusual for WhatsApp, and it voids Ban Insurance: a ban whose 30-day window overlaps any period this setting was on is not refunded. Defaults to false."
+ },
+ "simultaneousCallsCoverageExcludedUntil": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time",
+ "description": "Read-only. While `simultaneousCalls` is false, when Ban Insurance stops excluding this Number because of an earlier period with it on: 30 days after the latest period ended. A ban is excluded when the setting was on at any time in the 30 days before it, so until this time, a new ban is not refunded; from this time on, that period no longer excludes a ban. Null while `simultaneousCalls` is true, because the exclusion has no end yet, and null when no period ended in the last 30 days. Null does not mean the Number is covered: the other Ban Insurance conditions still apply."
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Increases on every change; 0 while the session uses the defaults. Send it as `expectedRevision` to refuse an update that would overwrite someone else's change."
+ },
+ "updatedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time",
+ "description": "When the settings were last changed, or null when the session uses the defaults."
+ }
+ },
+ "required": [
+ "callsEnabled",
+ "conferenceMode",
+ "inboundRoute",
+ "sipTrunkId",
+ "sipClaim",
+ "hostCloudApiCalls",
+ "simultaneousCalls",
+ "simultaneousCallsCoverageExcludedUntil",
+ "revision",
+ "updatedAt"
+ ],
+ "additionalProperties": false
+ },
+ "PlatformAccessGetCallSettingsResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PlatformAccessCallSettings"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "PlatformAccessUpdateCallSettingsResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PlatformAccessCallSettings"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "PlatformAccessUpdateCallSettingsRequest": {
+ "type": "object",
+ "properties": {
+ "callsEnabled": {
+ "type": "boolean",
+ "description": "Set to false to refuse new and incoming calls on the session; true allows them again."
+ },
+ "conferenceMode": {
+ "type": "boolean",
+ "description": "Set to true so that the participants you connect to a call hear each other as well as the WhatsApp party, or false so that each hears only the WhatsApp party."
+ },
+ "inboundRoute": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PlatformAccessCallInboundRoute"
+ },
+ {
+ "description": "Where incoming WhatsApp calls ring."
+ }
+ ]
+ },
+ "sipTrunkId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid",
+ "description": "The SIP trunk for `sip_trunk`: a trunk in the session's project whose direction is `outbound` or `both`. Required when switching the route to `sip_trunk`; omit it to keep the stored trunk. With `clients` send `null` or omit it: a trunk ID is refused, and `clients` clears the stored trunk."
+ },
+ "sipClaim": {
+ "type": "boolean",
+ "description": "Whether an answer from the SIP trunk claims the call."
+ },
+ "hostCloudApiCalls": {
+ "type": "boolean",
+ "description": "Set to true to have Polymorfa Calls answer the session's Cloud API calls instead of your Graph API integration. Applies to calls that arrive after the change."
+ },
+ "simultaneousCalls": {
+ "type": "boolean",
+ "description": "Set to true to let this Number hold more than one call at a time, with no limit. Turning it on requires `acknowledgeRisk: true` and a team API key; it voids Ban Insurance for any ban whose 30-day window overlaps a period it was on. Set to false to return to one call at a time. Applies to calls that start after the change."
+ },
+ "acknowledgeRisk": {
+ "type": "boolean",
+ "description": "Required as true when the update turns `simultaneousCalls` on. Confirms that you accept the unusual-behavior risk and the loss of Ban Insurance for this Number."
+ },
+ "expectedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Apply the update only if the settings still have this `revision` (0 for a session using the defaults). Otherwise the update fails with `409 state_conflict`."
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "operations": {
+ "get": {
+ "operationId": "getCallSettings",
+ "tags": ["Calls"],
+ "summary": "Get call settings for a session",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:read",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Call settings. Sessions without saved settings return the defaults.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessGetCallSettingsResponse"
+ }
+ }
+ },
+ "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 \u2014 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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "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:read"
+ },
+ "description": "**Required scope:** `sessions:read`",
+ "x-audience": "public"
+ },
+ "put": {
+ "operationId": "updateCallSettings",
+ "tags": ["Calls"],
+ "summary": "Update call settings for a session",
+ "description": "**Required scope:** `sessions:manage`\n\nChanges the settings you send; omitted settings keep their current values. Send `expectedRevision` to refuse the update when someone else changed the settings after you read them.",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessUpdateCallSettingsRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Call settings saved. Audio settings apply to a call the next time a participant answers or connects to it, including calls in progress. Routing settings apply to calls that arrive after the change.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessUpdateCallSettingsResponse"
+ }
+ }
+ },
+ "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 \u2014 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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformAccessPublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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": "`state_conflict`: the settings changed after `expectedRevision`.",
+ "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": {
+ "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"
+ }
+ }
+}
diff --git a/contracts/calls-coverage-end.candidate.json b/contracts/calls-coverage-end.candidate.json
new file mode 100644
index 00000000..7bf989d0
--- /dev/null
+++ b/contracts/calls-coverage-end.candidate.json
@@ -0,0 +1,76 @@
+{
+ "schemaVersion": 1,
+ "sourceAccepted": true,
+ "published": false,
+ "acceptedSource": {
+ "repository": "polymorfa/polymorfa",
+ "branch": "dev",
+ "commit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "tree": "b2f9fdfc575a0fabb5edb57b7780411f5e9b4b90",
+ "parents": [
+ "6ce6f6c975224abe8ab21eae96b381044f3a6073",
+ "4be63eb3310615f47b1c3dae410f1adbd5dc0727"
+ ],
+ "pullRequests": ["polymorfa/polymorfa#168", "polymorfa/polymorfa#798"],
+ "files": [
+ {
+ "path": "apps/api/packages/api/src/routes/call-settings.ts",
+ "sha256": "c39aa1ad8facc02b391974be7ba502293662797c5dce3626f4509ecbf7cae4eb"
+ },
+ {
+ "path": "apps/api/packages/api/src/db/calls/settings-repo.ts",
+ "sha256": "e72ac879bf6b685acf045039b58f8246a9dc068dcd1991ab7de510fbe8d7871e"
+ },
+ {
+ "path": "apps/api/docs/openapi.management.json",
+ "sha256": "3a4370816296cf96296d643e3d823185c64d354ca29ac24343cebff774d43319"
+ },
+ {
+ "path": "docs/api-reference/openapi.management.json",
+ "sha256": "af09de19a9152fe4977e5545213ccb09210e4a0ed3019020fb46c78b66f3f096"
+ },
+ {
+ "path": "docs/openapi.json",
+ "sha256": "03da2795efd1c1801d98f513a7585395d6522e8585ab4a8d2b5863f4e81d6443"
+ }
+ ]
+ },
+ "candidateCommit": "0b7b739493b089c386fc21f2cf76f05f60de9151",
+ "candidateTree": "1b5c55375b928e2a3f9068d180aa43bd5be61cb8",
+ "candidateParent": "4bf556ab46e23c6d87fa376151f1bd217f6f0812",
+ "apiVersion": "2026-09-22",
+ "sourceFiles": [
+ {
+ "path": "apps/api/packages/api/src/routes/call-settings.ts",
+ "sha256": "9023f1ac740072a4a67f0afc007ad7a03d112412187e95a232351a22ac72bf18"
+ },
+ {
+ "path": "apps/api/packages/api/src/db/calls/settings-repo.ts",
+ "sha256": "e72ac879bf6b685acf045039b58f8246a9dc068dcd1991ab7de510fbe8d7871e"
+ },
+ {
+ "path": "apps/api/docs/openapi.management.json",
+ "sha256": "f5544f6034bebc978ae2892ef6c885c7a8508d9f349f741b409518240ef7c2c9"
+ },
+ {
+ "path": "docs/api-reference/openapi.management.json",
+ "sha256": "9a8db97a52fbcf3efab47d7c342d9d65c187d5edcefabe19980e56a44ea65fe6"
+ },
+ {
+ "path": "docs/openapi.json",
+ "sha256": "fa17c29c961cc085cf4f63f5ff4522ca3fa0c0b80f04409e9a53d0c86a6d1d49"
+ }
+ ],
+ "fragmentPath": "contracts/calls-coverage-end.candidate.fragments.json",
+ "fragmentSha256": "ebf836d45a87eea629808f2b2701667d742a06df031c71b476d942cca61da79a",
+ "fixturePath": "contracts/fixtures/calls-coverage-end.candidate.json",
+ "fixtureSha256": "b7a2af6007dac4de11fc86fd236cd4d36bd4af2362a35078b1d5ee928fa4568b",
+ "operations": ["getCallSettings", "updateCallSettings"],
+ "field": "simultaneousCallsCoverageExcludedUntil",
+ "responseOnly": true,
+ "required": true,
+ "nullable": true,
+ "format": "date-time",
+ "acceptedContainingCommit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "privateConsoleSchemaNote": "At 77e9ce31 the private Console SessionCallSettings schema also carries the field. No SDK private route."
+}
diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json
index aaaf99b6..e5bf593b 100644
--- a/contracts/doc-snippets.json
+++ b/contracts/doc-snippets.json
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
- "sourceCommit": "4555c67a8ece59484dcb6b43ab3ad5adfe10bcbc",
- "sourceFilesSha256": "122eaf7a51d30047d5c6a1a899e579fa97820d8020329ce297722240826944c2",
+ "sourceCommit": "b1a4cfe15858ae029b3d4e40cd691fbede2d7ec4",
+ "sourceFilesSha256": "22d0df64227263fd4566f91efb3d668fe2867f8a0e36f6d0d5d878b8498d2e3a",
"apiVersion": "2026-09-22",
"apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99",
"specs": {
diff --git a/contracts/fixtures/calls-coverage-end.candidate.json b/contracts/fixtures/calls-coverage-end.candidate.json
new file mode 100644
index 00000000..71d487a7
--- /dev/null
+++ b/contracts/fixtures/calls-coverage-end.candidate.json
@@ -0,0 +1,33 @@
+{
+ "schemaVersion": 1,
+ "candidateSource": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "sourceAccepted": true,
+ "settings": {
+ "callsEnabled": true,
+ "conferenceMode": false,
+ "inboundRoute": "clients",
+ "sipTrunkId": null,
+ "sipClaim": false,
+ "hostCloudApiCalls": false,
+ "simultaneousCalls": false,
+ "revision": 2,
+ "updatedAt": null
+ },
+ "cases": [
+ {
+ "name": "off-future",
+ "on": false,
+ "until": "2026-11-22T12:34:56.789Z"
+ },
+ {
+ "name": "on-ongoing",
+ "on": true,
+ "until": null
+ },
+ {
+ "name": "off-no-recent-period",
+ "on": false,
+ "until": null
+ }
+ ]
+}
diff --git a/contracts/fixtures/passkeys.candidate.json b/contracts/fixtures/passkeys.candidate.json
new file mode 100644
index 00000000..b49a8d45
--- /dev/null
+++ b/contracts/fixtures/passkeys.candidate.json
@@ -0,0 +1,55 @@
+{
+ "schemaVersion": 1,
+ "candidateSource": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "sourceAccepted": true,
+ "session": "desk/1",
+ "ticketId": "ticket/?",
+ "issue": {
+ "challengeB64Url": "AQIDBAUGBwgJCgsMDQ4PEA",
+ "ttlSeconds": 300
+ },
+ "assertion": {
+ "ticketId": "ticket/?",
+ "assertion": {
+ "id": "aWQ",
+ "rawIdB64Url": "aWQ",
+ "response": {
+ "authenticatorDataB64": "AQID",
+ "clientDataJSONB64": "e30",
+ "signatureB64": "AQID",
+ "userHandleB64": "dXNlcg"
+ }
+ }
+ },
+ "confirm": {
+ "ticketId": "ticket/?",
+ "code": "ABCD-EF12"
+ },
+ "ticket": {
+ "version": 2,
+ "ticket": "ticket/?",
+ "session": "desk/1",
+ "rpId": "whatsapp.com",
+ "challengeB64Url": "AQIDBAUGBwgJCgsMDQ4PEA",
+ "userVerification": "required",
+ "expiresAt": "2026-10-23T16:00:00.123Z",
+ "timeoutMs": 120000
+ },
+ "assertionResult": {
+ "ok": true,
+ "ticket": "ticket/?",
+ "session": "desk/1"
+ },
+ "statuses": [
+ "none",
+ "challenge_pending",
+ "assertion_sending",
+ "assertion_submitted",
+ "code_available",
+ "confirmation_sending",
+ "waiting_for_pairing",
+ "completed",
+ "failed"
+ ],
+ "errors": [400, 403, 404, 409, 410, 413, 422, 502, 503]
+}
diff --git a/contracts/passkeys.candidate.fragments.json b/contracts/passkeys.candidate.fragments.json
new file mode 100644
index 00000000..6541c21d
--- /dev/null
+++ b/contracts/passkeys.candidate.fragments.json
@@ -0,0 +1,2400 @@
+{
+ "paths": {
+ "/messaging/{session}/passkey/challenge": {
+ "post": {
+ "operationId": "issuePasskeyChallenge",
+ "tags": ["Passkey"],
+ "summary": "Issue a single-use WebAuthn challenge ticket for a session",
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Use [QuickLink](/integrations/quicklink) for standard connections. Binds a WhatsApp-originated passkey challenge to one session with a short TTL. The extension runs the ceremony and the assertion comes back to the redeem endpoint.",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IssuePasskeyChallengeRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Challenge ticket issued",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IssuePasskeyChallengeResponse"
+ }
+ }
+ },
+ "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 \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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/PublicError"
+ },
+ "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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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/PublicError"
+ },
+ "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": "No passkey challenge pending for this session",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "410": {
+ "description": "Passkey ticket expired. Issue a new challenge.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_gone",
+ "message": "The requested retained resource has expired or was removed.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-gone"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "413": {
+ "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "422": {
+ "description": "Assertion is not bound to the ticket challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "502": {
+ "description": "Upstream WhatsApp request failed or its response could not be mapped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "upstream_error",
+ "code": "upstream_failure",
+ "message": "The messaging provider could not complete the request.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#upstream-failure"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "503": {
+ "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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": "Linked Device API sessions:manage"
+ },
+ "x-protocols": ["Linked Device API"]
+ }
+ },
+ "/messaging/{session}/passkey/challenge/{ticketId}": {
+ "get": {
+ "operationId": "getPasskeyChallenge",
+ "tags": ["Passkey"],
+ "summary": "Read a live passkey challenge ticket",
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Returns the ceremony options for a ticket that has not expired and has not been redeemed.",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "ticketId",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Live challenge ticket",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetPasskeyChallengeResponse"
+ }
+ }
+ },
+ "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 \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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/PublicError"
+ },
+ "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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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/PublicError"
+ },
+ "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": "No passkey challenge pending for this session",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "410": {
+ "description": "Passkey ticket expired. Issue a new challenge.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_gone",
+ "message": "The requested retained resource has expired or was removed.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-gone"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "422": {
+ "description": "Assertion is not bound to the ticket challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "502": {
+ "description": "Upstream WhatsApp request failed or its response could not be mapped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "upstream_error",
+ "code": "upstream_failure",
+ "message": "The messaging provider could not complete the request.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#upstream-failure"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "503": {
+ "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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": "Linked Device API sessions:manage"
+ },
+ "x-protocols": ["Linked Device API"]
+ }
+ },
+ "/messaging/{session}/passkey/assertion": {
+ "post": {
+ "operationId": "submitPasskeyAssertion",
+ "tags": ["Passkey"],
+ "summary": "Redeem a passkey ticket with the ceremony assertion",
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nThe complete HTTP body is limited to 1048576 UTF-8 bytes before JSON parsing. Credential IDs allow at most 1023 decoded bytes; user handles allow 64. Opaque assertion fields share the body budget without independent small caps. Your team must have direct-pairing access for the target Number. Single-use: verifies the assertion is bound to the ticket challenge, forwards it to the session runner, and consumes the ticket once acknowledged.",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubmitPasskeyAssertionRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Assertion accepted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubmitPasskeyAssertionResponse"
+ }
+ }
+ },
+ "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 \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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/PublicError"
+ },
+ "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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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/PublicError"
+ },
+ "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": "No passkey challenge pending for this session",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "410": {
+ "description": "Passkey ticket expired. Issue a new challenge.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_gone",
+ "message": "The requested retained resource has expired or was removed.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-gone"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "413": {
+ "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "422": {
+ "description": "Assertion is not bound to the ticket challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "502": {
+ "description": "Upstream WhatsApp request failed or its response could not be mapped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "upstream_error",
+ "code": "upstream_failure",
+ "message": "The messaging provider could not complete the request.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#upstream-failure"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "503": {
+ "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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": "Linked Device API sessions:manage"
+ },
+ "x-protocols": ["Linked Device API"]
+ }
+ },
+ "/messaging/{session}/passkey/confirmation": {
+ "post": {
+ "operationId": "confirmPasskey",
+ "tags": ["Passkey"],
+ "summary": "Confirm the current passkey pairing code",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nConfirm only after comparing the displayed code with the phone. Omit code only when the current runner reports skipHandoffUX. An accepted confirmation still waits for pairing completion.",
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ticketId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "code": {
+ "type": "string",
+ "pattern": "^[A-Z0-9]{4}-[A-Z0-9]{4}$"
+ }
+ },
+ "required": ["ticketId"],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Confirmation accepted; consult pairing state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ConfirmPasskeyResponse"
+ }
+ }
+ },
+ "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 \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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/PublicError"
+ },
+ "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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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/PublicError"
+ },
+ "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": "No passkey challenge pending for this session",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "410": {
+ "description": "Passkey ticket expired. Issue a new challenge.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_gone",
+ "message": "The requested retained resource has expired or was removed.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-gone"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "413": {
+ "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "422": {
+ "description": "Assertion is not bound to the ticket challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "502": {
+ "description": "Upstream WhatsApp request failed or its response could not be mapped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "upstream_error",
+ "code": "upstream_failure",
+ "message": "The messaging provider could not complete the request.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#upstream-failure"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "503": {
+ "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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": "Linked Device API sessions:manage"
+ },
+ "x-protocols": ["Linked Device API"]
+ },
+ "get": {
+ "operationId": "getPasskeyConfirmation",
+ "tags": ["Passkey"],
+ "summary": "Read the passkey round-trip state for a session",
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Reports where the ceremony stands: no round-trip, challenge pending, assertion submitted, code available for comparison, confirmation sending, waiting for pairing, completed, or failed.",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "x-required-scope": "sessions:manage",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Round-trip state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetPasskeyConfirmationResponse"
+ }
+ }
+ },
+ "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 \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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/PublicError"
+ },
+ "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": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "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#permission-denied"
+ }
+ }
+ },
+ "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/PublicError"
+ },
+ "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": "No passkey challenge pending for this session",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "410": {
+ "description": "Passkey ticket expired. Issue a new challenge.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_gone",
+ "message": "The requested retained resource has expired or was removed.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-gone"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "422": {
+ "description": "Assertion is not bound to the ticket challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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"
+ }
+ }
+ },
+ "502": {
+ "description": "Upstream WhatsApp request failed or its response could not be mapped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "upstream_error",
+ "code": "upstream_failure",
+ "message": "The messaging provider could not complete the request.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#upstream-failure"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "503": {
+ "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "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": "Linked Device API sessions:manage"
+ },
+ "x-protocols": ["Linked Device API"]
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "PublicError": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "invalid_request_error",
+ "authentication_error",
+ "permission_error",
+ "conflict_error",
+ "rate_limit_error",
+ "api_error",
+ "upstream_error"
+ ]
+ },
+ "code": {
+ "type": "string",
+ "enum": [
+ "invalid_parameter",
+ "invalid_credential",
+ "entitlement_required",
+ "cloud_template_authority_restricted",
+ "gate_limit_reached",
+ "method_not_allowed",
+ "permission_denied",
+ "missing_scope",
+ "resource_not_found",
+ "hms_not_enabled",
+ "media_fetch_failed",
+ "resource_gone",
+ "feature_unavailable",
+ "stream_cursor_invalid",
+ "stream_cursor_expired",
+ "stream_connection_limit_reached",
+ "state_conflict",
+ "team_deletion_scheduled",
+ "hybrid_routing_paused",
+ "hybrid_choice_required",
+ "hybrid_transition_ineligible",
+ "hybrid_automation_unavailable",
+ "hybrid_group_linked_devices_unavailable",
+ "hybrid_transport_unavailable",
+ "hybrid_target_reference_unavailable",
+ "send_outcome_unknown",
+ "idempotency_conflict",
+ "idempotency_in_progress",
+ "idempotency_completed",
+ "idempotency_outcome_unknown",
+ "call_claimed",
+ "call_not_ringing",
+ "connection_limit",
+ "unsupported_for_connection",
+ "call_permission_required",
+ "invalid_sip_trunk",
+ "calls_disabled",
+ "call_recipient_opted_out",
+ "call_destination_blocked",
+ "call_permission_request_limited",
+ "call_permission_granted",
+ "order_status_transition_invalid",
+ "order_cancellation_failed",
+ "call_opt_out_limit",
+ "number_restricted",
+ "sip_trunk_in_use",
+ "sip_trunk_revision_conflict",
+ "sip_trunk_limit",
+ "sip_unavailable",
+ "voice_not_enabled",
+ "provider_credential_invalid",
+ "provider_unavailable",
+ "asset_not_ready",
+ "voice_asset_in_use",
+ "voice_asset_revision_conflict",
+ "voice_unavailable",
+ "rate_limit_exceeded",
+ "internal_error",
+ "operation_not_supported",
+ "upstream_failure",
+ "service_unavailable",
+ "credential_verification_unavailable",
+ "session_not_ready",
+ "command_dispatch_failed",
+ "result_unknown",
+ "parent_bsuid_not_supported",
+ "recipient_not_on_whatsapp",
+ "conversation_window_closed",
+ "template_not_approved",
+ "media_too_large",
+ "whatsapp_rate_limited",
+ "new_chat_limit_reached",
+ "marketing_disabled_on_cloud_api",
+ "whatsapp_account_restricted",
+ "whatsapp_authorization_required",
+ "whatsapp_payment_method_required",
+ "whatsapp_number_not_registered",
+ "whatsapp_groups_ineligible",
+ "whatsapp_group_creation_paused",
+ "whatsapp_group_limit_reached",
+ "whatsapp_group_full",
+ "whatsapp_group_suspended",
+ "whatsapp_group_has_no_participants",
+ "bansafe_suspended",
+ "bansafe_org_suspended",
+ "bansafe_cold_blocked",
+ "bansafe_cold_held",
+ "bansafe_throttled",
+ "bansafe_daily_allowance_reached",
+ "bansafe_accounting_unavailable",
+ "bansafe_send_outcome_unknown",
+ "campaigns_not_entitled",
+ "campaign_throughput_capped",
+ "campaign_approval_required",
+ "campaign_approval_stale",
+ "campaign_variables_missing"
+ ]
+ },
+ "message": {
+ "type": "string"
+ },
+ "param": {
+ "type": "string",
+ "nullable": true
+ },
+ "request_id": {
+ "type": "string",
+ "description": "Matches the X-Request-Id response header. Include it when you contact support."
+ },
+ "request_log_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged."
+ }
+ },
+ "required": ["type", "code", "message", "param", "request_id"]
+ },
+ "data": {
+ "type": "string",
+ "nullable": true,
+ "enum": [null],
+ "description": "Always null for a failed request."
+ },
+ "docs": {
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "required": ["error", "data", "docs"]
+ },
+ "GetPasskeyConfirmationResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PasskeyConfirmation"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "SubmitPasskeyAssertionResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PasskeyAssertionResult"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "PasskeyAssertionResult": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "ticket": {
+ "type": "string"
+ },
+ "session": {
+ "type": "string"
+ }
+ },
+ "required": ["ok", "ticket", "session"]
+ },
+ "ConfirmPasskeyResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PasskeyConfirmation"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "SubmitPasskeyAssertionRequest": {
+ "type": "object",
+ "properties": {
+ "ticketId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "assertion": {
+ "$ref": "#/components/schemas/PasskeyAssertion"
+ }
+ },
+ "required": ["ticketId", "assertion"]
+ },
+ "GetPasskeyChallengeResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PasskeyTicket"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "PasskeyTicket": {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "number",
+ "enum": [2]
+ },
+ "ticket": {
+ "type": "string"
+ },
+ "session": {
+ "type": "string"
+ },
+ "rpId": {
+ "type": "string"
+ },
+ "challengeB64Url": {
+ "type": "string"
+ },
+ "userVerification": {
+ "type": "string",
+ "enum": ["preferred", "required", "discouraged"]
+ },
+ "expiresAt": {
+ "type": "string"
+ },
+ "timeoutMs": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "version",
+ "ticket",
+ "session",
+ "rpId",
+ "challengeB64Url",
+ "userVerification",
+ "expiresAt"
+ ]
+ },
+ "IssuePasskeyChallengeRequest": {
+ "type": "object",
+ "properties": {
+ "challengeB64Url": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Optional expected challenge. If supplied it must match the current runner snapshot; it never supplies ceremony options."
+ },
+ "userVerification": {
+ "type": "string",
+ "enum": ["preferred", "required", "discouraged"],
+ "description": "Ignored. The current runner determines user verification.",
+ "deprecated": true
+ },
+ "ttlSeconds": {
+ "type": "integer",
+ "description": "Requested ticket lifetime, independent of the WebAuthn timeout. Defaults to 300 seconds and is clamped to 255\u2013300 seconds, then capped by the current challenge deadline. No new ticket is issued with less than 255 seconds remaining. The budget includes one minute to enter the ticket, two 10-second verification calls, 30 seconds for explicit consent, 15 seconds for popup startup, a five-second minimum prompt, and a 120-second post-prompt reserve for final verification, one minute to copy the assertion, both direct server RPCs, access checks and transit. A short remaining window is withheld from new callers without retiring an advertised live ticket. Renewal waits for expiry; an explicitly shorter lifetime constraint may retire an unreserved ticket. In-flight forwarding blocks replacement. Current runner authority is revalidated throughout."
+ },
+ "timeoutMs": {
+ "type": "integer",
+ "minimum": 0,
+ "exclusiveMinimum": true,
+ "description": "Ignored. The current runner determines ceremony timeout.",
+ "deprecated": true
+ }
+ }
+ },
+ "IssuePasskeyChallengeResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/PasskeyTicket"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "PasskeyAssertion": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1364
+ },
+ "rawIdB64Url": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1364
+ },
+ "response": {
+ "$ref": "#/components/schemas/PasskeyAssertionResponse"
+ }
+ },
+ "required": ["id", "rawIdB64Url", "response"]
+ },
+ "PasskeyAssertionResponse": {
+ "type": "object",
+ "properties": {
+ "authenticatorDataB64": {
+ "type": "string",
+ "minLength": 1
+ },
+ "clientDataJSONB64": {
+ "type": "string",
+ "minLength": 1
+ },
+ "signatureB64": {
+ "type": "string",
+ "minLength": 1
+ },
+ "userHandleB64": {
+ "type": "string",
+ "maxLength": 86
+ }
+ },
+ "required": [
+ "authenticatorDataB64",
+ "clientDataJSONB64",
+ "signatureB64"
+ ]
+ },
+ "PasskeyConfirmation": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "none",
+ "challenge_pending",
+ "assertion_sending",
+ "assertion_submitted",
+ "code_available",
+ "confirmation_sending",
+ "waiting_for_pairing",
+ "completed",
+ "failed"
+ ]
+ },
+ "ticketId": {
+ "type": "string"
+ },
+ "code": {
+ "type": "string"
+ },
+ "skipHandoffUX": {
+ "type": "boolean"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["status"]
+ }
+ }
+ }
+}
diff --git a/contracts/passkeys.candidate.json b/contracts/passkeys.candidate.json
new file mode 100644
index 00000000..ddaec945
--- /dev/null
+++ b/contracts/passkeys.candidate.json
@@ -0,0 +1,213 @@
+{
+ "schemaVersion": 1,
+ "sourceAccepted": true,
+ "published": false,
+ "acceptedSource": {
+ "repository": "polymorfa/polymorfa",
+ "branch": "dev",
+ "commit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "tree": "b2f9fdfc575a0fabb5edb57b7780411f5e9b4b90",
+ "parents": [
+ "6ce6f6c975224abe8ab21eae96b381044f3a6073",
+ "4be63eb3310615f47b1c3dae410f1adbd5dc0727"
+ ],
+ "pullRequests": ["polymorfa/polymorfa#168", "polymorfa/polymorfa#798"],
+ "files": [
+ {
+ "path": "apps/api/packages/api/src/routes/session-passkey.ts",
+ "sha256": "04fdd4beb424a83f2fb63f5090af26992b06c60a2123bb2f35a5dc31150bb890"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/session-passkey-tickets.ts",
+ "sha256": "e5f0b4b941c506db0e0bb42236c02d7f36b134fd1b0f635678925dcdad1276c0"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/passkey-assertion-limits.ts",
+ "sha256": "f442c6d9b79504263f807343838a2217c0c99149a196bce0318679783dfeed52"
+ },
+ {
+ "path": "apps/api/packages/api/src/middleware/client-rules.ts",
+ "sha256": "96fdd17e89e50bf252c64521ba553b182d5aacce7e8bf9116e673b627c5d825d"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/credential-capabilities.ts",
+ "sha256": "96afee6a1f4019c3d5b8b881cfcd3cfd1e73e585442d279bdabb942a762b9342"
+ },
+ {
+ "path": "apps/api/packages/api/src/composition/messaging-native.ts",
+ "sha256": "3626f2773cbb51aa7e06bc7a47176e05cd5b3b0c07a30623d29906decdcba81a"
+ },
+ {
+ "path": "apps/api/docs/openapi.json",
+ "sha256": "0dae88e889900491fc79c592785142810490a92b71e988b4138a64fdaf78002a"
+ },
+ {
+ "path": "docs/api-reference/openapi.json",
+ "sha256": "ea163e6639cb954cb6d2ba3bb7ebc06ea86eb185d1553e72ffc6ffe030cd55f8"
+ },
+ {
+ "path": "docs/openapi.json",
+ "sha256": "03da2795efd1c1801d98f513a7585395d6522e8585ab4a8d2b5863f4e81d6443"
+ }
+ ]
+ },
+ "candidateSource": {
+ "repository": "polymorfa/polymorfa",
+ "commit": "c9902b86e4a1b52092029486cc16c1699456d539",
+ "tree": "9925573ab3e03270e81c719a4fc828c86f2be74c",
+ "parent": "45f4a8511bd604bb4a40e70748b1b45a3d94cb9a",
+ "files": [
+ {
+ "path": "apps/api/packages/api/src/routes/session-passkey.ts",
+ "sha256": "04fdd4beb424a83f2fb63f5090af26992b06c60a2123bb2f35a5dc31150bb890"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/session-passkey-tickets.ts",
+ "sha256": "e5f0b4b941c506db0e0bb42236c02d7f36b134fd1b0f635678925dcdad1276c0"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/passkey-assertion-limits.ts",
+ "sha256": "f442c6d9b79504263f807343838a2217c0c99149a196bce0318679783dfeed52"
+ },
+ {
+ "path": "apps/api/packages/api/src/middleware/client-rules.ts",
+ "sha256": "96fdd17e89e50bf252c64521ba553b182d5aacce7e8bf9116e673b627c5d825d"
+ },
+ {
+ "path": "apps/api/packages/api/src/lib/credential-capabilities.ts",
+ "sha256": "009b4093178524fb9627bf074951000bef98c6dfd1d3d8164e65072222ec8076"
+ },
+ {
+ "path": "apps/api/packages/api/src/composition/messaging-native.ts",
+ "sha256": "368c86c010b641d39efdb984fffb5346b1e3e33cf3e64a18557b4f36cc1b31f4"
+ },
+ {
+ "path": "apps/api/docs/openapi.json",
+ "sha256": "d753e0f97270509d068ebdc26ec10a8edf6c51021eeec65c60c6f5493d7504ec"
+ },
+ {
+ "path": "docs/api-reference/openapi.json",
+ "sha256": "3167cbd926f5ca2de88d55852c65e6b840e5eb722e1d6cae0ffbf648dc1c2029"
+ },
+ {
+ "path": "docs/openapi.json",
+ "sha256": "3810d4665f3fe4050e1ec0ca87da8362b8ce7a35734c7cefb0ee47a7d9de04cf"
+ }
+ ]
+ },
+ "fragment": {
+ "path": "contracts/passkeys.candidate.fragments.json",
+ "sha256": "4099350294f54141d49aa25caa9ecb833823d98f7b563ba4d9478da024f58722"
+ },
+ "operations": [
+ {
+ "operationId": "issuePasskeyChallenge",
+ "method": "POST",
+ "path": "/messaging/{session}/passkey/challenge",
+ "audience": "customer server",
+ "requiredScope": "sessions:manage",
+ "serverCredentialsOnly": true,
+ "automaticRetries": 0,
+ "counterparts": {
+ "typescript": "passkeys.issueChallenge",
+ "go": "Passkeys().IssueChallenge",
+ "python": "passkeys.issue_challenge",
+ "php": "passkeys->issueChallenge",
+ "rust": "passkeys().issue_challenge",
+ "dotnet": "Passkeys.IssueChallengeAsync"
+ }
+ },
+ {
+ "operationId": "getPasskeyChallenge",
+ "method": "GET",
+ "path": "/messaging/{session}/passkey/challenge/{ticketId}",
+ "audience": "customer server",
+ "requiredScope": "sessions:manage",
+ "serverCredentialsOnly": true,
+ "automaticRetries": "ordinary request policy",
+ "counterparts": {
+ "typescript": "passkeys.getChallenge",
+ "go": "Passkeys().GetChallenge",
+ "python": "passkeys.get_challenge",
+ "php": "passkeys->getChallenge",
+ "rust": "passkeys().get_challenge",
+ "dotnet": "Passkeys.GetChallengeAsync"
+ }
+ },
+ {
+ "operationId": "submitPasskeyAssertion",
+ "method": "POST",
+ "path": "/messaging/{session}/passkey/assertion",
+ "audience": "customer server",
+ "requiredScope": "sessions:manage",
+ "serverCredentialsOnly": true,
+ "automaticRetries": 0,
+ "counterparts": {
+ "typescript": "passkeys.submitAssertion",
+ "go": "Passkeys().SubmitAssertion",
+ "python": "passkeys.submit_assertion",
+ "php": "passkeys->submitAssertion",
+ "rust": "passkeys().submit_assertion",
+ "dotnet": "Passkeys.SubmitAssertionAsync"
+ }
+ },
+ {
+ "operationId": "getPasskeyConfirmation",
+ "method": "GET",
+ "path": "/messaging/{session}/passkey/confirmation",
+ "audience": "customer server",
+ "requiredScope": "sessions:manage",
+ "serverCredentialsOnly": true,
+ "automaticRetries": "ordinary request policy",
+ "counterparts": {
+ "typescript": "passkeys.getConfirmation",
+ "go": "Passkeys().GetConfirmation",
+ "python": "passkeys.get_confirmation",
+ "php": "passkeys->getConfirmation",
+ "rust": "passkeys().get_confirmation",
+ "dotnet": "Passkeys.GetConfirmationAsync"
+ }
+ },
+ {
+ "operationId": "confirmPasskey",
+ "method": "POST",
+ "path": "/messaging/{session}/passkey/confirmation",
+ "audience": "customer server",
+ "requiredScope": "sessions:manage",
+ "serverCredentialsOnly": true,
+ "automaticRetries": 0,
+ "counterparts": {
+ "typescript": "passkeys.confirm",
+ "go": "Passkeys().Confirm",
+ "python": "passkeys.confirm",
+ "php": "passkeys->confirm",
+ "rust": "passkeys().confirm",
+ "dotnet": "Passkeys.ConfirmAsync"
+ }
+ }
+ ],
+ "excluded": [
+ {
+ "operationId": "verifyExtensionPasskeyTicket",
+ "reason": "Extension/hosted participant ceremony relay, not machine SDK audience"
+ },
+ {
+ "operationId": "submitQuickLinkPasskey",
+ "reason": "Extension/hosted participant ceremony relay, not machine SDK audience"
+ },
+ {
+ "operationId": "confirmQuickLinkPasskey",
+ "reason": "Extension/hosted participant ceremony relay, not machine SDK audience"
+ }
+ ],
+ "coexistingCandidate": {
+ "commit": "0b7b739493b089c386fc21f2cf76f05f60de9151",
+ "laterTestOnlyHead": "fce83265c1c99b75de3678191e5b07aac6de2cf0",
+ "sourceAccepted": true,
+ "published": false,
+ "acceptedContainingCommit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5",
+ "note": "Calls response-only field merged to API dev by #798 (0f6e92ec); 77e9ce31 contains both producers."
+ },
+ "acceptedPinsUnchanged": true,
+ "coverageLedgerCredit": false
+}
diff --git a/docs/passkeys.md b/docs/passkeys.md
new file mode 100644
index 00000000..11538bf0
--- /dev/null
+++ b/docs/passkeys.md
@@ -0,0 +1,44 @@
+# Direct server passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+| Operation | TypeScript | Go | Python | PHP | Rust | .NET |
+| ---------------------- | ------------------------ | -------------------------- | ------------------------- | ------------------------- | --------------------------- | ----------------------------- |
+| issuePasskeyChallenge | passkeys.issueChallenge | Passkeys().IssueChallenge | passkeys.issue_challenge | passkeys->issueChallenge | passkeys().issue_challenge | Passkeys.IssueChallengeAsync |
+| getPasskeyChallenge | passkeys.getChallenge | Passkeys().GetChallenge | passkeys.get_challenge | passkeys->getChallenge | passkeys().get_challenge | Passkeys.GetChallengeAsync |
+| submitPasskeyAssertion | passkeys.submitAssertion | Passkeys().SubmitAssertion | passkeys.submit_assertion | passkeys->submitAssertion | passkeys().submit_assertion | Passkeys.SubmitAssertionAsync |
+| getPasskeyConfirmation | passkeys.getConfirmation | Passkeys().GetConfirmation | passkeys.get_confirmation | passkeys->getConfirmation | passkeys().get_confirmation | Passkeys.GetConfirmationAsync |
+| confirmPasskey | passkeys.confirm | Passkeys().Confirm | passkeys.confirm | passkeys->confirm | passkeys().confirm | Passkeys.ConfirmAsync |
+
+Confirmation states are `none`, `challenge_pending`, `assertion_sending`,
+`assertion_submitted`, `code_available`, `confirmation_sending`,
+`waiting_for_pairing`, `completed` and `failed`. Optional code, ticket, handoff and
+error fields retain their presence. A missing code does not authorize confirmation.
+
+New Rust request structs use optional fields or `Default` where applicable; public
+struct literals must name their fields. These additions do not change existing
+Calls struct shapes. New .NET records leave existing constructors and Deconstruct
+methods unchanged.
diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md
index 16e16721..eac6ef32 100644
--- a/packages/dotnet/README.md
+++ b/packages/dotnet/README.md
@@ -1033,3 +1033,60 @@ These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
+
+
+### Call settings exclusion end (Unreleased)
+
+Call-settings GET and PUT responses include the read-only
+`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null.
+While `simultaneousCalls` is on, null means the exclusion has no end yet.
+When it is off, a future timestamp is 30 days after the latest period ended.
+A ban is excluded when the setting was on at any time in the 30 days before it,
+so until that timestamp a new ban is not refunded. At that timestamp the earlier
+period no longer excludes a ban. Null while off
+means no recent period was reported. Neither null nor an elapsed timestamp
+guarantees coverage; all other Ban Insurance conditions still apply.
+
+The server calculates this value. Do not send it in an update or calculate it
+from the current switch alone. This addition is unreleased; source preparation
+does not establish API deployment or package publication.
+
+The existing nine-position constructor and Deconstruct remain unchanged.
+The new nullable response value is an init-only property.
+
+## Direct passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+```csharp
+var ticket = await client.Passkeys.IssueChallengeAsync(session);
+await client.Passkeys.GetChallengeAsync(session, ticket.Data.Data.Ticket);
+await client.Passkeys.SubmitAssertionAsync(session, suppliedAssertion);
+var state = await client.Passkeys.GetConfirmationAsync(session);
+// Invoke only after an explicit caller decision based on the current state.
+Task>> ConfirmExplicitly() =>
+ client.Passkeys.ConfirmAsync(session, suppliedConfirmation);
+```
diff --git a/packages/dotnet/src/Polymorfa.Sdk/Calls.cs b/packages/dotnet/src/Polymorfa.Sdk/Calls.cs
index 7934269f..896d4b71 100644
--- a/packages/dotnet/src/Polymorfa.Sdk/Calls.cs
+++ b/packages/dotnet/src/Polymorfa.Sdk/Calls.cs
@@ -25,7 +25,11 @@ public sealed record CallPermissionActions(CallPermissionAction? RequestPermissi
public record CallPermissionState(string Status, string? ExpiresAt, string? Source, string? UpdatedAt, string? CheckedAt, bool Fresh, CallPermissionActions? Actions);
public sealed record CallPermission(string Status, string? ExpiresAt, string? Source, string? UpdatedAt, string? CheckedAt, bool Fresh, CallPermissionActions? Actions, ConversationReference Conversation) : CallPermissionState(Status, ExpiresAt, Source, UpdatedAt, CheckedAt, Fresh, Actions);
public sealed record CallCheck(bool Allowed, string? Refusal, CallPermissionState? Permission);
-public sealed record SessionCallSettings(bool CallsEnabled, bool ConferenceMode, string InboundRoute, string? SipTrunkId, bool SipClaim, bool HostCloudApiCalls, long Revision, string? UpdatedAt, bool SimultaneousCalls);
+public sealed record SessionCallSettings(bool CallsEnabled, bool ConferenceMode, string InboundRoute, string? SipTrunkId, bool SipClaim, bool HostCloudApiCalls, long Revision, string? UpdatedAt, bool SimultaneousCalls)
+{
+ /// Read-only RFC3339 exclusion end. Null is not a coverage guarantee.
+ public string? SimultaneousCallsCoverageExcludedUntil { get; init; }
+}
public sealed record UpdateCallSettingsRequest(bool? CallsEnabled = null, bool? ConferenceMode = null, string? InboundRoute = null, [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault)] PatchValue SipTrunkId = default, bool? SipClaim = null, bool? HostCloudApiCalls = null, long? ExpectedRevision = null, bool? SimultaneousCalls = null, bool? AcknowledgeRisk = null);
public sealed record CallReportClient(string Sdk, string Version, string Platform);
public sealed record CallQuality(int? RttMs = null, int? JitterMs = null, int? PacketsLost = null, int? PacketsReceived = null, int? Reconnects = null, string? AudioCodec = null, string? VideoCodec = null, string? CandidateType = null);
diff --git a/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs b/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs
index 3b2f11c9..8febc34f 100644
--- a/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs
+++ b/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs
@@ -5,6 +5,7 @@ namespace Polymorfa.Sdk;
public sealed class MessagingClient : IDisposable
{
private readonly HttpTransport http;
+ public Passkeys Passkeys { get; }
public MessagingSessions Sessions { get; }
public Messages Messages { get; }
public QuickLinks QuickLinks { get; }
@@ -40,7 +41,7 @@ public sealed class MessagingClient : IDisposable
public MessagingClient(Credential credential, ClientOptions? options = null)
{
if (credential.Kind != CredentialKind.ClientToken) credential.RequireServer();
- http = new(credential, options ?? new()); Sessions = new(http); Messages = new(http); QuickLinks = new(http); Webhooks = new(http); Calls = new(http); CloudOnboarding = new(http); Testing = new(http); ObservationPolicies = new(http); HybridLink = new(http); Templates = new(http); Campaigns = new(http); CloudGraph = new(http); CloudTemplates = new(http); Business = new(http); Channels = new(http); QuickReplies = new(http); BanSafe = new(http); Contacts = new(http); OfficialGroups = new(http); Catalogs = new(http); Groups = new(http); Chats = new(http); Labels = new(http); Presence = new(http); Profile = new(http); Privacy = new(http); ClientTokens = new(http); Identities = new(http); Users = new(http); Media = new(http); WhatsAppMedia = new(http); HistorySync = new(http);
+ http = new(credential, options ?? new()); Passkeys = new(http); Sessions = new(http); Messages = new(http); QuickLinks = new(http); Webhooks = new(http); Calls = new(http); CloudOnboarding = new(http); Testing = new(http); ObservationPolicies = new(http); HybridLink = new(http); Templates = new(http); Campaigns = new(http); CloudGraph = new(http); CloudTemplates = new(http); Business = new(http); Channels = new(http); QuickReplies = new(http); BanSafe = new(http); Contacts = new(http); OfficialGroups = new(http); Catalogs = new(http); Groups = new(http); Chats = new(http); Labels = new(http); Presence = new(http); Profile = new(http); Privacy = new(http); ClientTokens = new(http); Identities = new(http); Users = new(http); Media = new(http); WhatsAppMedia = new(http); HistorySync = new(http);
}
public Task> PaginateRawAsync(RawRequest request, RawPageDecoder decode, string cursorParameter = "cursor") => RawCursorPage.LoadAsync(http, request, decode, cursorParameter);
public Task> RawAsync(HttpMethod method, string path, JsonElement? body = null, RequestOptions? options = null, IReadOnlyList>? query = null) => http.RequestAsync(method, path, body, options, query);
diff --git a/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs b/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs
new file mode 100644
index 00000000..269d3f0e
--- /dev/null
+++ b/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs
@@ -0,0 +1,58 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Polymorfa.Sdk;
+
+/// Supplied current-challenge guard and TTL seconds. UserVerification and TimeoutMs are deprecated, ignored server inputs.
+public sealed record IssuePasskeyChallenge(string? ChallengeB64Url = null, long? TtlSeconds = null, string? UserVerification = null, long? TimeoutMs = null);
+public sealed record PasskeyTicket(int Version, string Ticket, string Session, string RpId, string ChallengeB64Url, string UserVerification, string ExpiresAt, long? TimeoutMs = null);
+public sealed record PasskeyAssertionResponse(string AuthenticatorDataB64, [property: JsonPropertyName("clientDataJSONB64")] string ClientDataJSONB64, string SignatureB64, string? UserHandleB64 = null);
+public sealed record PasskeyAssertion(string Id, string RawIdB64Url, PasskeyAssertionResponse Response);
+public sealed record SubmitPasskeyAssertion(string TicketId, PasskeyAssertion Assertion);
+public sealed record PasskeyAssertionResult(bool Ok, string Ticket, string Session);
+public sealed record ConfirmPasskey(string TicketId, string? Code = null);
+public sealed record PasskeyConfirmation(string Status, string? TicketId = null, string? Code = null, [property: JsonPropertyName("skipHandoffUX")] bool? SkipHandoffUX = null, string? Error = null);
+/// Direct server operations. No WebAuthn ceremony, polling or automatic confirmation.
+public sealed class Passkeys
+{
+ private readonly HttpTransport http;
+ internal Passkeys(HttpTransport http) => this.http = http;
+ public static IReadOnlyList Statuses { get; } = Array.AsReadOnly(new[] { "none", "challenge_pending", "assertion_sending", "assertion_submitted", "code_available", "confirmation_sending", "waiting_for_pairing", "completed", "failed" });
+ private string Path(string session)
+ {
+ http.Credential.RequireServer();
+ if (string.IsNullOrEmpty(session)) throw Invalid("Provide a Number ID.");
+ return $"/messaging/{Uri.EscapeDataString(session)}/passkey";
+ }
+ private static PolymorfaValidationException Invalid(string message) => new(message, "invalid_parameter");
+ private Task>> Write(string path, object body, RequestOptions? options)
+ {
+ if (JsonSerializer.SerializeToUtf8Bytes(body, HttpTransport.Json).Length > 1048576) throw Invalid("Passkey request exceeds 1 MiB.");
+ return http.RequestAsync>(HttpMethod.Post, path, body, (options ?? new()) with { MaxNetworkRetries = 0 });
+ }
+ public Task>> IssueChallengeAsync(string session, IssuePasskeyChallenge? body = null, RequestOptions? options = null)
+ {
+ var path = Path(session); body ??= new();
+ if (body.ChallengeB64Url is { Length: 0 } || body.TimeoutMs is <= 0 || body.UserVerification is not null and not ("required" or "preferred" or "discouraged")) throw Invalid("Invalid challenge options.");
+ return Write(path + "/challenge", body, options);
+ }
+ public Task>> GetChallengeAsync(string session, string ticketId, RequestOptions? options = null)
+ {
+ var path = Path(session);
+ if (string.IsNullOrEmpty(ticketId)) throw Invalid("Provide a ticket.");
+ return http.RequestAsync>(HttpMethod.Get, path + "/challenge/" + Uri.EscapeDataString(ticketId), null, options);
+ }
+ public Task>> SubmitAssertionAsync(string session, SubmitPasskeyAssertion body, RequestOptions? options = null)
+ {
+ var path = Path(session); var a = body.Assertion; var r = a?.Response;
+ if (string.IsNullOrEmpty(body.TicketId) || a is null || string.IsNullOrEmpty(a.Id) || a.Id.Length > 1364 || string.IsNullOrEmpty(a.RawIdB64Url) || a.RawIdB64Url.Length > 1364 || r is null || string.IsNullOrEmpty(r.AuthenticatorDataB64) || string.IsNullOrEmpty(r.ClientDataJSONB64) || string.IsNullOrEmpty(r.SignatureB64) || r.UserHandleB64?.Length > 86) throw Invalid("Provide the ticket and bounded assertion.");
+ return Write(path + "/assertion", body, options);
+ }
+ public Task>> GetConfirmationAsync(string session, RequestOptions? options = null) => http.RequestAsync>(HttpMethod.Get, Path(session) + "/confirmation", null, options);
+ public Task>> ConfirmAsync(string session, ConfirmPasskey body, RequestOptions? options = null)
+ {
+ var path = Path(session); var code = body.Code;
+ if (string.IsNullOrEmpty(body.TicketId) || code is not null && (code.Length != 9 || code[4] != '-' || code.Where((_, i) => i != 4).Any(c => c is not (>= 'A' and <= 'Z') and not (>= '0' and <= '9')))) throw Invalid("Provide the ticket and optional confirmation code.");
+ return Write(path + "/confirmation", body, options);
+ }
+}
diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs
new file mode 100644
index 00000000..a23516ae
--- /dev/null
+++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs
@@ -0,0 +1,30 @@
+using System.Text.Json;
+using Polymorfa.Sdk;
+using static ResourceTests;
+
+internal static class CallSettingsCoverageEndTests
+{
+ public static async Task RunAsync()
+ {
+ var old = new SessionCallSettings(true, false, "clients", null, false, false, 2, null, false);
+ var (enabled, conference, route, trunk, claim, hosted, revision, updated, simultaneous) = old;
+ ResourceTests.Equal(revision, 2L);
+ ResourceTests.Equal(old.SimultaneousCallsCoverageExcludedUntil, null);
+ foreach (var item in new (bool On, string? Until)[] { (false, "2026-11-22T12:34:56.789Z"), (true, null), (false, null) })
+ foreach (var method in new[] { "GET", "PUT" })
+ {
+ var until = item.Until;
+ var data = new { callsEnabled = true, conferenceMode = false, inboundRoute = "clients", sipTrunkId = (string?)null, sipClaim = false, hostCloudApiCalls = false, revision = 2, updatedAt = (string?)null, simultaneousCalls = item.On, simultaneousCallsCoverageExcludedUntil = until };
+ var response = JsonSerializer.Serialize(new { success = true, data });
+ await Messaging(method, "/platform/sessions/s/call-settings", method == "GET" ? null : "{\"callsEnabled\":false}", response, async c =>
+ {
+ var value = method == "GET" ? await c.Calls.RetrieveCallSettingsAsync("s") : await c.Calls.UpdateCallSettingsAsync("s", new(CallsEnabled: false));
+ ResourceTests.Equal(value.Data.Data.SimultaneousCallsCoverageExcludedUntil, until);
+ ResourceTests.Equal(value.Data.Data.Revision, 2L);
+ using var roundtrip = JsonDocument.Parse(JsonSerializer.Serialize(value.Data.Data, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
+ ResourceTests.Equal(roundtrip.RootElement.GetProperty("simultaneousCallsCoverageExcludedUntil").GetString(), until);
+ });
+ }
+ True(typeof(UpdateCallSettingsRequest).GetProperty("SimultaneousCallsCoverageExcludedUntil") is null);
+ }
+}
diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs
index bc74783c..3e60e3e4 100644
--- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs
+++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs
@@ -7,6 +7,7 @@ internal static class CallsTests
private const string Settings = "{\"success\":true,\"data\":{\"callsEnabled\":true,\"conferenceMode\":false,\"inboundRoute\":\"clients\",\"sipTrunkId\":null,\"sipClaim\":false,\"hostCloudApiCalls\":false,\"revision\":2,\"updatedAt\":null}}";
public static async Task RunAsync()
{
+ await CallSettingsCoverageEndTests.RunAsync();
await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":true,\"acknowledgeRisk\":true}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: true, AcknowledgeRisk: true))).Data.Data.Revision, 2L));
await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":false}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: false))).Data.Data.Revision, 2L));
await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":true}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: true))).Data.Data.Revision, 2L));
diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs
new file mode 100644
index 00000000..b602af73
--- /dev/null
+++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+using Polymorfa.Sdk;
+
+internal static class PasskeyTests
+{
+ private sealed class BrokenHandler(bool nonJson) : HttpMessageHandler
+ {
+ internal int Count;
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Count++;
+ if (request.Content is not null) await request.Content.ReadAsByteArrayAsync(cancellationToken);
+ if (!nonJson) throw new HttpRequestException("Connection lost after upload.");
+ return new HttpResponseMessage(System.Net.HttpStatusCode.BadGateway) { Content = new StringContent("unavailable", System.Text.Encoding.UTF8, "text/html") };
+ }
+ }
+ public static async Task RunAsync()
+ {
+ var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+ var fixturePath = Environment.GetEnvironmentVariable("PASSKEY_FIXTURE");
+ if (fixturePath is null)
+ {
+ DirectoryInfo? root = new DirectoryInfo(Directory.GetCurrentDirectory());
+ while (root is not null && !File.Exists(System.IO.Path.Combine(root.FullName, "contracts/fixtures/passkeys.candidate.json"))) root = root.Parent;
+ fixturePath = root is null ? throw new Exception("Passkey fixture root not found.") : System.IO.Path.Combine(root.FullName, "contracts/fixtures/passkeys.candidate.json");
+ }
+ using var doc = JsonDocument.Parse(File.ReadAllText(fixturePath));
+ var f = doc.RootElement;
+ var issue = JsonSerializer.Deserialize(f.GetProperty("issue"), options)!;
+ var assertion = JsonSerializer.Deserialize(f.GetProperty("assertion"), options)!;
+ var confirm = JsonSerializer.Deserialize(f.GetProperty("confirm"), options)!;
+ string Reply(string key) => "{\"success\":true,\"data\":" + f.GetProperty(key).GetRawText() + "}";
+ await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/challenge", f.GetProperty("issue").GetRawText(), Reply("ticket"), async c => { var r = await c.Passkeys.IssueChallengeAsync("desk/1", issue); ResourceTests.Equal(r.Data.Data.ExpiresAt, "2026-10-23T16:00:00.123Z"); ResourceTests.Equal(r.Data.Data.TimeoutMs, 120000L); });
+ await ResourceTests.Messaging("GET", "/messaging/desk%2F1/passkey/challenge/ticket%2F%3F", null, Reply("ticket"), async c => ResourceTests.Equal((await c.Passkeys.GetChallengeAsync("desk/1", "ticket/?")).Data.Data.Ticket, "ticket/?"));
+ await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/assertion", f.GetProperty("assertion").GetRawText(), Reply("assertionResult"), async c => ResourceTests.True((await c.Passkeys.SubmitAssertionAsync("desk/1", assertion)).Data.Data.Ok));
+ foreach (var status in f.GetProperty("statuses").EnumerateArray())
+ await ResourceTests.Messaging("GET", "/messaging/desk/passkey/confirmation", null, "{\"success\":true,\"data\":{\"status\":\"" + status.GetString() + "\",\"skipHandoffUX\":false}}", async c => { var r = await c.Passkeys.GetConfirmationAsync("desk"); ResourceTests.Equal(r.Data.Data.Status, status.GetString()); ResourceTests.Equal(r.Data.Data.SkipHandoffUX, false); ResourceTests.Equal(r.Data.Data.Code, null); });
+ await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/confirmation", f.GetProperty("confirm").GetRawText(), "{\"success\":true,\"data\":{\"status\":\"waiting_for_pairing\"}}", async c => ResourceTests.Equal((await c.Passkeys.ConfirmAsync("desk/1", confirm)).Data.Data.Status, "waiting_for_pairing"));
+ for (var i = 0; i < 3; i++)
+ {
+ using var wire = new WireFixture { Authorization = "Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k" };
+ string[] suffix = ["challenge", "assertion", "confirmation"];
+ string[] key = ["issue", "assertion", "confirm"];
+ var serving = wire.ServeAsync("POST", "/messaging/desk/passkey/" + suffix[i], f.GetProperty(key[i]).GetRawText(), "{\"error\":{\"code\":\"future_passkey\",\"message\":\"refused\"}}", 503);
+ using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = wire.Url, MaxNetworkRetries = 3, Timeout = TimeSpan.FromSeconds(3) });
+ var o = new RequestOptions { MaxNetworkRetries = 3, IdempotencyKey = "caller-key" };
+ try { if (i == 0) await c.Passkeys.IssueChallengeAsync("desk", issue, o); else if (i == 1) await c.Passkeys.SubmitAssertionAsync("desk", assertion, o); else await c.Passkeys.ConfirmAsync("desk", confirm, o); throw new Exception("Expected server refusal."); }
+ catch (PolymorfaServerException e) { ResourceTests.Equal(e.Code, "future_passkey"); ResourceTests.Equal(e.Metadata?.Attempts, 1); }
+ await serving; ResourceTests.True(!wire.Pending);
+ }
+ foreach (var status in f.GetProperty("errors").EnumerateArray())
+ {
+ using var wire = new WireFixture { Authorization = "Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k" };
+ var serving = wire.ServeAsync("POST", "/messaging/desk/passkey/confirmation", f.GetProperty("confirm").GetRawText(), "{\"error\":{\"code\":\"future_passkey\",\"message\":\"refused\"}}", status.GetInt32());
+ using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = wire.Url, Timeout = TimeSpan.FromSeconds(3) });
+ try { await c.Passkeys.ConfirmAsync("desk", confirm); throw new Exception("Expected refusal."); }
+ catch (PolymorfaException e) { ResourceTests.Equal(e.Code, "future_passkey"); ResourceTests.Equal(e.Status, status.GetInt32()); }
+ await serving;
+ }
+ foreach (var nonJson in new[] { false, true })
+ for (var i = 0; i < 3; i++)
+ {
+ using var handler = new BrokenHandler(nonJson);
+ using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = new("https://api.example"), TransportHandler = handler });
+ var o = new RequestOptions { MaxNetworkRetries = 3, IdempotencyKey = "caller" };
+ try { if (i == 0) await c.Passkeys.IssueChallengeAsync("desk", issue, o); else if (i == 1) await c.Passkeys.SubmitAssertionAsync("desk", assertion, o); else await c.Passkeys.ConfirmAsync("desk", confirm, o); throw new Exception("Expected unknown outcome."); }
+ catch (PolymorfaException) { ResourceTests.Equal(handler.Count, 1); }
+ }
+ using (var handler = new BrokenHandler(false))
+ using (var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = new("https://api.example"), TransportHandler = handler }))
+ {
+ foreach (var bad in new[] { assertion with { Assertion = assertion.Assertion with { Id = new string('x', 1365) } }, assertion with { Assertion = assertion.Assertion with { Response = assertion.Assertion.Response with { UserHandleB64 = new string('x', 87) } } }, assertion with { Assertion = assertion.Assertion with { Response = assertion.Assertion.Response with { SignatureB64 = new string('x', 1048576) } } } })
+ {
+ try { await c.Passkeys.SubmitAssertionAsync("s", bad); throw new Exception("Expected local bound refusal."); }
+ catch (PolymorfaValidationException) { ResourceTests.Equal(handler.Count, 0); }
+ }
+ }
+ await ResourceTests.Messaging("POST", "/messaging/desk/passkey/challenge", "{}", Reply("ticket"), async c => { await c.Passkeys.IssueChallengeAsync("desk"); });
+ await ResourceTests.Messaging("POST", "/messaging/desk/passkey/confirmation", "{\"ticketId\":\"ticket\"}", "{\"success\":true,\"data\":{\"status\":\"none\"}}", async c => { await c.Passkeys.ConfirmAsync("desk", new("ticket")); });
+ var maximum = assertion with { Assertion = assertion.Assertion with { Id = new string('x', 1364), RawIdB64Url = new string('x', 1364), Response = assertion.Assertion.Response with { UserHandleB64 = new string('x', 86) } } };
+ await ResourceTests.Messaging("POST", "/messaging/desk/passkey/assertion", JsonSerializer.Serialize(maximum, options), Reply("assertionResult"), async c => { await c.Passkeys.SubmitAssertionAsync("desk", maximum); });
+ Console.WriteLine("PASS passkeys five wire operations, nine outcomes, three single-attempt writes");
+ }
+}
diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs
index 90bf3ee3..ec207347 100644
--- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs
+++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs
@@ -238,3 +238,5 @@
await CloudOrderQuantityTests.RunAsync();
CampaignWebhookMetadataTests.Run();
+
+await PasskeyTests.RunAsync();
diff --git a/packages/go/README.md b/packages/go/README.md
index f54a772a..d1bd3db0 100644
--- a/packages/go/README.md
+++ b/packages/go/README.md
@@ -834,3 +834,66 @@ These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
+
+
+### Call settings exclusion end (Unreleased)
+
+Call-settings GET and PUT responses include the read-only
+`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null.
+While `simultaneousCalls` is on, null means the exclusion has no end yet.
+When it is off, a future timestamp is 30 days after the latest period ended.
+A ban is excluded when the setting was on at any time in the 30 days before it,
+so until that timestamp a new ban is not refunded. At that timestamp the earlier
+period no longer excludes a ban. Null while off
+means no recent period was reported. Neither null nor an elapsed timestamp
+guarantees coverage; all other Ban Insurance conditions still apply.
+
+The server calculates this value. Do not send it in an update or calculate it
+from the current switch alone. This addition is unreleased; source preparation
+does not establish API deployment or package publication.
+
+Prefer keyed response struct literals when constructing synthetic settings.
+
+## Direct passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+```go
+ticket, err := client.Passkeys().IssueChallenge(ctx, session, polymorfa.IssuePasskeyChallengeRequest{})
+if err != nil { return err }
+_, err = client.Passkeys().GetChallenge(ctx, session, ticket.Data.Data.Ticket)
+if err != nil { return err }
+_, err = client.Passkeys().SubmitAssertion(ctx, session, suppliedAssertion)
+if err != nil { return err }
+_, err = client.Passkeys().GetConfirmation(ctx, session)
+if err != nil { return err }
+// Call only after an explicit caller decision based on the current state.
+confirmExplicitly := func() error {
+ _, err := client.Passkeys().Confirm(ctx, session, suppliedConfirmation)
+ return err
+}
+_ = confirmExplicitly
+```
diff --git a/packages/go/calls_coverage_end_test.go b/packages/go/calls_coverage_end_test.go
new file mode 100644
index 00000000..5df050bd
--- /dev/null
+++ b/packages/go/calls_coverage_end_test.go
@@ -0,0 +1,95 @@
+package polymorfa
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+func TestCallSettingsCoverageEndResponseOnly(t *testing.T) {
+ data, err := os.ReadFile("../../contracts/fixtures/calls-coverage-end.candidate.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fixture struct {
+ Settings map[string]any
+ Cases []struct {
+ Name string
+ On bool
+ Until *string
+ }
+ }
+ if err := json.Unmarshal(data, &fixture); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range fixture.Cases {
+ for _, method := range []string{"GET", "PUT"} {
+ t.Run(c.Name+method, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != method || r.URL.Path != "/platform/sessions/support/call-settings" {
+ t.Error(r.Method, r.URL)
+ }
+ if method == "PUT" {
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ if len(body) != 1 || body["callsEnabled"] != false {
+ t.Error(body)
+ }
+ }
+ settings := map[string]any{}
+ for k, v := range fixture.Settings {
+ settings[k] = v
+ }
+ settings["simultaneousCalls"] = c.On
+ settings["simultaneousCallsCoverageExcludedUntil"] = c.Until
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": settings})
+ }))
+ defer server.Close()
+ client := mustMessaging(t, server.URL)
+ var response Response[Envelope[SessionCallSettings]]
+ var err error
+ if method == "GET" {
+ response, err = client.VoIP().RetrieveCallSettings(context.Background(), "support")
+ } else {
+ response, err = client.VoIP().UpdateCallSettings(context.Background(), "support", UpdateCallSettingsRequest{CallsEnabled: callsBool(false)})
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ actual := response.Data.Data.SimultaneousCallsCoverageExcludedUntil
+ if (actual == nil) != (c.Until == nil) || actual != nil && *actual != *c.Until {
+ t.Fatal(actual, c.Until)
+ }
+ if response.Data.Data.SimultaneousCalls != c.On || response.Data.Data.Revision != 2 {
+ t.Fatal(response.Data.Data)
+ }
+ })
+ }
+ }
+ var older SessionCallSettings
+ legacy, err := json.Marshal(fixture.Settings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(legacy, &older); err != nil || older.SimultaneousCallsCoverageExcludedUntil != nil {
+ t.Fatal(older, err)
+ }
+ encoded, err := json.Marshal(older)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var roundtrip map[string]any
+ if err := json.Unmarshal(encoded, &roundtrip); err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := roundtrip["simultaneousCallsCoverageExcludedUntil"]; !ok || v != nil {
+ t.Fatal(roundtrip)
+ }
+
+}
diff --git a/packages/go/passkeys.go b/packages/go/passkeys.go
new file mode 100644
index 00000000..23b411b9
--- /dev/null
+++ b/packages/go/passkeys.go
@@ -0,0 +1,167 @@
+package polymorfa
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+)
+
+// Passkeys relays supplied assertions. It never runs a browser ceremony.
+type Passkeys struct{ t *transport }
+
+func (c *MessagingClient) Passkeys() *Passkeys { return &Passkeys{c.t} }
+
+type IssuePasskeyChallengeRequest struct {
+ ChallengeB64URL *string `json:"challengeB64Url,omitempty"`
+ TTLSeconds *int64 `json:"ttlSeconds,omitempty"`
+ // Deprecated: ignored by the server; runner authority determines this value.
+ UserVerification *string `json:"userVerification,omitempty"`
+ // Deprecated: ignored by the server; runner authority determines this value.
+ TimeoutMS *int64 `json:"timeoutMs,omitempty"`
+}
+type PasskeyTicket struct {
+ Version int `json:"version"`
+ Ticket string `json:"ticket"`
+ Session string `json:"session"`
+ RPID string `json:"rpId"`
+ ChallengeB64URL string `json:"challengeB64Url"`
+ UserVerification string `json:"userVerification"`
+ ExpiresAt string `json:"expiresAt"`
+ TimeoutMS *int64 `json:"timeoutMs,omitempty"`
+}
+type PasskeyAssertionResponse struct {
+ AuthenticatorDataB64 string `json:"authenticatorDataB64"`
+ ClientDataJSONB64 string `json:"clientDataJSONB64"`
+ SignatureB64 string `json:"signatureB64"`
+ UserHandleB64 *string `json:"userHandleB64,omitempty"`
+}
+type PasskeyAssertion struct {
+ ID string `json:"id"`
+ RawIDB64URL string `json:"rawIdB64Url"`
+ Response PasskeyAssertionResponse `json:"response"`
+}
+type SubmitPasskeyAssertionRequest struct {
+ TicketID string `json:"ticketId"`
+ Assertion PasskeyAssertion `json:"assertion"`
+}
+type PasskeyAssertionResult struct {
+ OK bool `json:"ok"`
+ Ticket string `json:"ticket"`
+ Session string `json:"session"`
+}
+type ConfirmPasskeyRequest struct {
+ TicketID string `json:"ticketId"`
+ Code *string `json:"code,omitempty"`
+}
+type PasskeyConfirmation struct {
+ Status string `json:"status"`
+ TicketID *string `json:"ticketId,omitempty"`
+ Code *string `json:"code,omitempty"`
+ SkipHandoffUX *bool `json:"skipHandoffUX,omitempty"`
+ Error *string `json:"error,omitempty"`
+}
+
+var PasskeyConfirmationStatuses = []string{"none", "challenge_pending", "assertion_sending", "assertion_submitted", "code_available", "confirmation_sending", "waiting_for_pairing", "completed", "failed"}
+
+func passkeyPath(s string) (string, error) {
+ if s == "" {
+ return "", validation("Provide a session.")
+ }
+ return messagingPath(s) + "/passkey", nil
+}
+func passkeyChars(s string) int {
+ n := 0
+ for _, r := range s {
+ n++
+ if r > 0xffff {
+ n++
+ }
+ }
+ return n
+}
+func passkeyBudget(body any) error {
+ data, err := json.Marshal(body)
+ if err != nil || len(data) > 1048576 {
+ return validation("Passkey request exceeds the 1 MiB JSON body budget.")
+ }
+ return nil
+}
+func (r *Passkeys) IssueChallenge(ctx context.Context, s string, body IssuePasskeyChallengeRequest, o ...RequestOptions) (Response[Envelope[PasskeyTicket]], error) {
+ if err := serverOnly(r.t); err != nil {
+ return Response[Envelope[PasskeyTicket]]{}, err
+ }
+ p, err := passkeyPath(s)
+ if err != nil {
+ return Response[Envelope[PasskeyTicket]]{}, err
+ }
+ if body.ChallengeB64URL != nil && *body.ChallengeB64URL == "" || body.TimeoutMS != nil && *body.TimeoutMS <= 0 || body.UserVerification != nil && *body.UserVerification != "required" && *body.UserVerification != "preferred" && *body.UserVerification != "discouraged" {
+ return Response[Envelope[PasskeyTicket]]{}, validation("Invalid passkey challenge options.")
+ }
+ if err = passkeyBudget(body); err != nil {
+ return Response[Envelope[PasskeyTicket]]{}, err
+ }
+ return request[Envelope[PasskeyTicket]](ctx, r.t, "POST", p+"/challenge", nil, singleSubmission{body}, noRetry(options(o)))
+}
+func (r *Passkeys) GetChallenge(ctx context.Context, s, ticket string, o ...RequestOptions) (Response[Envelope[PasskeyTicket]], error) {
+ if err := serverOnly(r.t); err != nil {
+ return Response[Envelope[PasskeyTicket]]{}, err
+ }
+ p, err := passkeyPath(s)
+ if err != nil {
+ return Response[Envelope[PasskeyTicket]]{}, err
+ }
+ if ticket == "" {
+ return Response[Envelope[PasskeyTicket]]{}, validation("Provide a ticket ID.")
+ }
+ return request[Envelope[PasskeyTicket]](ctx, r.t, "GET", p+"/challenge/"+escaped(ticket), nil, nil, options(o))
+}
+func (r *Passkeys) SubmitAssertion(ctx context.Context, s string, body SubmitPasskeyAssertionRequest, o ...RequestOptions) (Response[Envelope[PasskeyAssertionResult]], error) {
+ if err := serverOnly(r.t); err != nil {
+ return Response[Envelope[PasskeyAssertionResult]]{}, err
+ }
+ p, err := passkeyPath(s)
+ if err != nil {
+ return Response[Envelope[PasskeyAssertionResult]]{}, err
+ }
+ a := body.Assertion
+ v := a.Response
+ if body.TicketID == "" || a.ID == "" || passkeyChars(a.ID) > 1364 || a.RawIDB64URL == "" || passkeyChars(a.RawIDB64URL) > 1364 || v.AuthenticatorDataB64 == "" || v.ClientDataJSONB64 == "" || v.SignatureB64 == "" || v.UserHandleB64 != nil && passkeyChars(*v.UserHandleB64) > 86 {
+ return Response[Envelope[PasskeyAssertionResult]]{}, validation("Invalid passkey assertion shape.")
+ }
+ if err = passkeyBudget(body); err != nil {
+ return Response[Envelope[PasskeyAssertionResult]]{}, err
+ }
+ return request[Envelope[PasskeyAssertionResult]](ctx, r.t, "POST", p+"/assertion", nil, singleSubmission{body}, noRetry(options(o)))
+}
+func (r *Passkeys) GetConfirmation(ctx context.Context, s string, o ...RequestOptions) (Response[Envelope[PasskeyConfirmation]], error) {
+ if err := serverOnly(r.t); err != nil {
+ return Response[Envelope[PasskeyConfirmation]]{}, err
+ }
+ p, err := passkeyPath(s)
+ if err != nil {
+ return Response[Envelope[PasskeyConfirmation]]{}, err
+ }
+ return request[Envelope[PasskeyConfirmation]](ctx, r.t, "GET", p+"/confirmation", nil, nil, options(o))
+}
+func (r *Passkeys) Confirm(ctx context.Context, s string, body ConfirmPasskeyRequest, o ...RequestOptions) (Response[Envelope[PasskeyConfirmation]], error) {
+ if err := serverOnly(r.t); err != nil {
+ return Response[Envelope[PasskeyConfirmation]]{}, err
+ }
+ p, err := passkeyPath(s)
+ if err != nil {
+ return Response[Envelope[PasskeyConfirmation]]{}, err
+ }
+ if body.TicketID == "" {
+ return Response[Envelope[PasskeyConfirmation]]{}, validation("Provide a ticket ID.")
+ }
+ if body.Code != nil {
+ v := *body.Code
+ if len(v) != 9 || v[4] != '-' || strings.Trim(v[:4]+v[5:], "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != "" {
+ return Response[Envelope[PasskeyConfirmation]]{}, validation("Invalid passkey confirmation code.")
+ }
+ }
+ if err = passkeyBudget(body); err != nil {
+ return Response[Envelope[PasskeyConfirmation]]{}, err
+ }
+ return request[Envelope[PasskeyConfirmation]](ctx, r.t, "POST", p+"/confirmation", nil, singleSubmission{body}, noRetry(options(o)))
+}
diff --git a/packages/go/passkeys_test.go b/packages/go/passkeys_test.go
new file mode 100644
index 00000000..cc9db3cd
--- /dev/null
+++ b/packages/go/passkeys_test.go
@@ -0,0 +1,281 @@
+package polymorfa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func TestPasskeyFiveWire(t *testing.T) {
+ data, err := os.ReadFile("../../contracts/fixtures/passkeys.candidate.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f struct {
+ Issue IssuePasskeyChallengeRequest
+ Assertion SubmitPasskeyAssertionRequest
+ Confirm ConfirmPasskeyRequest
+ Ticket PasskeyTicket
+ AssertionResult PasskeyAssertionResult
+ Statuses []string
+ }
+ if err := json.Unmarshal(data, &f); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 5; i++ {
+ method, path := "GET", "/messaging/desk%2F1/passkey/confirmation"
+ var body, reply any
+ switch i {
+ case 0:
+ method = "POST"
+ path = "/messaging/desk%2F1/passkey/challenge"
+ body = f.Issue
+ reply = f.Ticket
+ case 1:
+ path = "/messaging/desk%2F1/passkey/challenge/ticket%2F%3F"
+ reply = f.Ticket
+ case 2:
+ method = "POST"
+ path = "/messaging/desk%2F1/passkey/assertion"
+ body = f.Assertion
+ reply = f.AssertionResult
+ case 3:
+ reply = PasskeyConfirmation{Status: "code_available", TicketID: &f.Confirm.TicketID, Code: f.Confirm.Code}
+ case 4:
+ method = "POST"
+ body = f.Confirm
+ reply = PasskeyConfirmation{Status: "waiting_for_pairing"}
+ }
+ count := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ count++
+ if r.Method != method || r.URL.EscapedPath() != path {
+ t.Error(r.Method, r.URL.EscapedPath(), path)
+ }
+ if r.Header.Get("Authorization") == "" || r.Header.Get("Polymorfa-Version") != "2026-09-22" {
+ t.Error(r.Header)
+ }
+ if body != nil {
+ var actual any
+ _ = json.NewDecoder(r.Body).Decode(&actual)
+ expected, _ := json.Marshal(body)
+ got, _ := json.Marshal(actual)
+ if string(got) != string(expected) {
+ var want any
+ _ = json.Unmarshal(expected, &want)
+ b, _ := json.Marshal(want)
+ if string(got) != string(b) {
+ t.Error(string(got), string(b))
+ }
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": reply})
+ }))
+ c := mustMessaging(t, server.URL).Passkeys()
+ switch i {
+ case 0:
+ v, e := c.IssueChallenge(context.Background(), "desk/1", f.Issue)
+ if e != nil || v.Data.Data.TimeoutMS == nil || *v.Data.Data.TimeoutMS != 120000 {
+ t.Fatal(v, e)
+ }
+ case 1:
+ v, e := c.GetChallenge(context.Background(), "desk/1", "ticket/?")
+ if e != nil || v.Data.Data.ExpiresAt != f.Ticket.ExpiresAt {
+ t.Fatal(v, e)
+ }
+ case 2:
+ v, e := c.SubmitAssertion(context.Background(), "desk/1", f.Assertion)
+ if e != nil || !v.Data.Data.OK {
+ t.Fatal(v, e)
+ }
+ case 3:
+ v, e := c.GetConfirmation(context.Background(), "desk/1")
+ if e != nil || v.Data.Data.Status != "code_available" {
+ t.Fatal(v, e)
+ }
+ case 4:
+ v, e := c.Confirm(context.Background(), "desk/1", f.Confirm)
+ if e != nil || v.Data.Data.Status != "waiting_for_pairing" {
+ t.Fatal(v, e)
+ }
+ }
+ server.Close()
+ if count != 1 {
+ t.Fatal(count)
+ }
+ }
+}
+
+func TestPasskeyBoundaries(t *testing.T) {
+ data, _ := os.ReadFile("../../contracts/fixtures/passkeys.candidate.json")
+ var f struct {
+ Issue IssuePasskeyChallengeRequest
+ Assertion SubmitPasskeyAssertionRequest
+ Confirm ConfirmPasskeyRequest
+ Statuses []string
+ Errors []int
+ }
+ if e := json.Unmarshal(data, &f); e != nil {
+ t.Fatal(e)
+ }
+ ctx := context.Background()
+ retries := 3
+ o := RequestOptions{MaxNetworkRetries: &retries, IdempotencyKey: "caller"}
+ for _, status := range f.Statuses {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"status": status, "skipHandoffUX": false}})
+ }))
+ v, e := mustMessaging(t, server.URL).Passkeys().GetConfirmation(ctx, "s")
+ server.Close()
+ if e != nil || v.Data.Data.Status != status || v.Data.Data.SkipHandoffUX == nil || *v.Data.Data.SkipHandoffUX || v.Data.Data.Code != nil {
+ t.Fatal(v, e)
+ }
+ }
+ for _, status := range f.Errors {
+ var count atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ count.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(`{"error":{"code":"future_passkey","message":"refused"}}`))
+ }))
+ _, e := mustMessaging(t, server.URL).Passkeys().Confirm(ctx, "s", f.Confirm, o)
+ server.Close()
+ var api *Error
+ if !errors.As(e, &api) || api.Status != status || api.Code != "future_passkey" || count.Load() != 1 {
+ t.Fatal(e, count.Load())
+ }
+ }
+ for _, mode := range []string{"503", "non-json", "network"} {
+ for i := 0; i < 3; i++ {
+ var count atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ count.Add(1)
+ if mode == "network" {
+ conn, _, e := w.(http.Hijacker).Hijack()
+ if e == nil {
+ _ = conn.Close()
+ }
+ return
+ }
+ if mode == "503" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(503)
+ _, _ = w.Write([]byte(`{"error":{"code":"future_passkey","message":"refused"}}`))
+ } else {
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(502)
+ _, _ = w.Write([]byte("unavailable"))
+ }
+ }))
+ r := mustMessaging(t, server.URL).Passkeys()
+ var e error
+ switch i {
+ case 0:
+ _, e = r.IssueChallenge(ctx, "s", f.Issue, o)
+ case 1:
+ _, e = r.SubmitAssertion(ctx, "s", f.Assertion, o)
+ case 2:
+ _, e = r.Confirm(ctx, "s", f.Confirm, o)
+ }
+ server.Close()
+ if e == nil || count.Load() != 1 {
+ t.Fatal(mode, i, e, count.Load())
+ }
+ }
+ }
+ for _, body := range []any{IssuePasskeyChallengeRequest{}, ConfirmPasskeyRequest{TicketID: "t"}} {
+ bytes, _ := json.Marshal(body)
+ var fields map[string]any
+ _ = json.Unmarshal(bytes, &fields)
+ if _, ok := fields["code"]; ok {
+ t.Fatal(fields)
+ }
+ if _, ok := fields["challengeB64Url"]; ok {
+ t.Fatal(fields)
+ }
+ }
+ bad := f.Assertion
+ bad.Assertion.ID = strings.Repeat("x", 1365)
+ if _, e := mustMessaging(t, "http://127.0.0.1:1").Passkeys().SubmitAssertion(ctx, "s", bad); e == nil {
+ t.Fatal("oversized credential accepted")
+ }
+ bad = f.Assertion
+ bad.Assertion.Response.SignatureB64 = strings.Repeat("x", 1048576)
+ if e := passkeyBudget(bad); e == nil {
+ t.Fatal("body budget accepted")
+ }
+}
+
+func TestPasskeyMaximumWire(t *testing.T) {
+ handle := strings.Repeat("x", 86)
+ body := SubmitPasskeyAssertionRequest{TicketID: "t", Assertion: PasskeyAssertion{ID: strings.Repeat("x", 1364), RawIDB64URL: strings.Repeat("x", 1364), Response: PasskeyAssertionResponse{AuthenticatorDataB64: "AQID", ClientDataJSONB64: "e30", SignatureB64: "AQID", UserHandleB64: &handle}}}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var got SubmitPasskeyAssertionRequest
+ if e := json.NewDecoder(r.Body).Decode(&got); e != nil {
+ t.Error(e)
+ }
+ if got.Assertion.ID != body.Assertion.ID || got.Assertion.RawIDB64URL != body.Assertion.RawIDB64URL || got.Assertion.Response.UserHandleB64 == nil || *got.Assertion.Response.UserHandleB64 != handle {
+ t.Error("maximum wire fields changed")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true,"ticket":"t","session":"s"}}`))
+ }))
+ defer server.Close()
+ if _, e := mustMessaging(t, server.URL).Passkeys().SubmitAssertion(context.Background(), "s", body); e != nil {
+ t.Fatal(e)
+ }
+}
+
+// A caller idempotency key must not let net/http rewind and resend a
+// single-use passkey write on a reused connection.
+func TestPasskeyWritesOfferNoBodyRewind(t *testing.T) {
+ handle := "AQID"
+ assertion := SubmitPasskeyAssertionRequest{TicketID: "t", Assertion: PasskeyAssertion{ID: "id", RawIDB64URL: "id", Response: PasskeyAssertionResponse{AuthenticatorDataB64: "AQID", ClientDataJSONB64: "e30", SignatureB64: "AQID", UserHandleB64: &handle}}}
+ rewindable := map[string]bool{}
+ config := orgConfig("https://api.fixture.invalid")
+ config.HTTPClient = &http.Client{Transport: mediaRoundTripper(func(req *http.Request) (*http.Response, error) {
+ if req.Method == "POST" {
+ rewindable[req.URL.Path] = req.GetBody != nil
+ if req.Header.Get("Idempotency-Key") != "caller" {
+ t.Error("caller idempotency key not sent")
+ }
+ }
+ data := `{"success":true,"data":{"status":"none"}}`
+ if strings.HasSuffix(req.URL.Path, "/assertion") {
+ data = `{"success":true,"data":{"ok":true,"ticket":"t","session":"s"}}`
+ }
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(data)), Request: req}, nil
+ })}
+ c, err := NewMessagingClient(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, o := context.Background(), RequestOptions{IdempotencyKey: "caller"}
+ if _, e := c.Passkeys().IssueChallenge(ctx, "s", IssuePasskeyChallengeRequest{}, o); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := c.Passkeys().SubmitAssertion(ctx, "s", assertion, o); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := c.Passkeys().Confirm(ctx, "s", ConfirmPasskeyRequest{TicketID: "t"}, o); e != nil {
+ t.Fatal(e)
+ }
+ if len(rewindable) != 3 {
+ t.Fatalf("expected three passkey writes, saw %v", rewindable)
+ }
+ for path, rewind := range rewindable {
+ if rewind {
+ t.Errorf("%s offers a body rewind", path)
+ }
+ }
+}
diff --git a/packages/go/voip.go b/packages/go/voip.go
index 6bfd0f2a..079262ab 100644
--- a/packages/go/voip.go
+++ b/packages/go/voip.go
@@ -109,15 +109,17 @@ type PreviewedCallLink struct {
IsAdmin bool `json:"isAdmin"`
}
type SessionCallSettings struct {
- SimultaneousCalls bool `json:"simultaneousCalls"`
- CallsEnabled bool `json:"callsEnabled"`
- ConferenceMode bool `json:"conferenceMode"`
- InboundRoute string `json:"inboundRoute"`
- SIPTrunkID *string `json:"sipTrunkId"`
- SIPClaim bool `json:"sipClaim"`
- HostCloudAPICalls bool `json:"hostCloudApiCalls"`
- Revision int64 `json:"revision"`
- UpdatedAt *string `json:"updatedAt"`
+ // Read-only server projection. Nil is not a coverage guarantee.
+ SimultaneousCallsCoverageExcludedUntil *string `json:"simultaneousCallsCoverageExcludedUntil"`
+ SimultaneousCalls bool `json:"simultaneousCalls"`
+ CallsEnabled bool `json:"callsEnabled"`
+ ConferenceMode bool `json:"conferenceMode"`
+ InboundRoute string `json:"inboundRoute"`
+ SIPTrunkID *string `json:"sipTrunkId"`
+ SIPClaim bool `json:"sipClaim"`
+ HostCloudAPICalls bool `json:"hostCloudApiCalls"`
+ Revision int64 `json:"revision"`
+ UpdatedAt *string `json:"updatedAt"`
}
// NullableString preserves the difference between omitted and explicitly null.
diff --git a/packages/php/README.md b/packages/php/README.md
index 27a06003..0ca1c39b 100644
--- a/packages/php/README.md
+++ b/packages/php/README.md
@@ -987,3 +987,56 @@ These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
+
+
+### Call settings exclusion end (Unreleased)
+
+Call-settings GET and PUT responses include the read-only
+`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null.
+While `simultaneousCalls` is on, null means the exclusion has no end yet.
+When it is off, a future timestamp is 30 days after the latest period ended.
+A ban is excluded when the setting was on at any time in the 30 days before it,
+so until that timestamp a new ban is not refunded. At that timestamp the earlier
+period no longer excludes a ban. Null while off
+means no recent period was reported. Neither null nor an elapsed timestamp
+guarantees coverage; all other Ban Insurance conditions still apply.
+
+The server calculates this value. Do not send it in an update or calculate it
+from the current switch alone. This addition is unreleased; source preparation
+does not establish API deployment or package publication.
+
+## Direct passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+```php
+$ticket = $client->passkeys->issueChallenge($session);
+$client->passkeys->getChallenge($session, $ticket->data['data']['ticket']);
+$client->passkeys->submitAssertion($session, $suppliedAssertion);
+$state = $client->passkeys->getConfirmation($session);
+// Invoke only after an explicit caller decision based on the current state.
+$confirmExplicitly = fn () => $client->passkeys->confirm($session, $suppliedConfirmation);
+```
diff --git a/packages/php/src/MessagingClient.php b/packages/php/src/MessagingClient.php
index abd0791c..acbfd74b 100644
--- a/packages/php/src/MessagingClient.php
+++ b/packages/php/src/MessagingClient.php
@@ -9,6 +9,7 @@
final readonly class MessagingClient
{
+ public Resources\Passkeys $passkeys;
public BanSafe $banSafe;
public Resources\Calls $calls;
public Resources\MessagingCampaigns $campaigns;
@@ -56,6 +57,7 @@ public function __construct(
?ClientInterface $http = null
) {
$transport = new HttpTransport($credential, $baseUrl, $apiVersion, $timeout, $maxNetworkRetries, $proxy, $http);
+ $this->passkeys = new Resources\Passkeys($transport);
$this->banSafe = new BanSafe($transport);
$this->calls = new Resources\Calls($transport);
$this->campaigns = new Resources\MessagingCampaigns($transport);
diff --git a/packages/php/src/PasskeyModels.php b/packages/php/src/PasskeyModels.php
new file mode 100644
index 00000000..0702357c
--- /dev/null
+++ b/packages/php/src/PasskeyModels.php
@@ -0,0 +1,19 @@
+server();
+ return '/messaging/'.$this->segment($session).'/passkey';
+ }
+ private static function text(mixed $value, ?int $limit = null): bool
+ {
+ if (!is_string($value) || $value === '') { return false; }
+ if ($limit === null) { return true; }
+ // JSON escaped UTF-16 units, without requiring an optional mbstring extension.
+ $encoded = json_encode($value, JSON_THROW_ON_ERROR);
+ preg_match_all('/\\\\u[0-9a-fA-F]{4}|\\\\.|[^"\\\\]/', substr($encoded, 1, -1), $units);
+ return count($units[0]) <= $limit;
+ }
+ /** @param array $body */
+ private static function budget(array $body): void
+ {
+ if (strlen(json_encode((object)$body, JSON_THROW_ON_ERROR)) > 1048576) { throw new ValidationException('Passkey request exceeds 1 MiB.'); }
+ }
+ /**
+ * Callers can pass any array at runtime, so validate the received shape, not the phpdoc.
+ * @param array $body
+ */
+ private static function checkIssue(array $body): void
+ {
+ if ((array_key_exists('challengeB64Url', $body) && !self::text($body['challengeB64Url'])) || (array_key_exists('ttlSeconds', $body) && !is_int($body['ttlSeconds'])) || (array_key_exists('timeoutMs', $body) && (!is_int($body['timeoutMs']) || $body['timeoutMs'] <= 0)) || (array_key_exists('userVerification', $body) && !in_array($body['userVerification'], ['required','preferred','discouraged'], true))) { throw new ValidationException('Invalid challenge options.'); }
+ }
+ /** @param array $body */
+ private static function checkSubmit(array $body): void
+ {
+ $a = $body['assertion'] ?? null; $r = is_array($a) ? ($a['response'] ?? null) : null;
+ if (!self::text($body['ticketId'] ?? null) || !is_array($a) || !self::text($a['id'] ?? null,1364) || !self::text($a['rawIdB64Url'] ?? null,1364) || !is_array($r) || !self::text($r['authenticatorDataB64'] ?? null) || !self::text($r['clientDataJSONB64'] ?? null) || !self::text($r['signatureB64'] ?? null)) { throw new ValidationException('Provide the ticket and bounded assertion.'); }
+ if (array_key_exists('userHandleB64',$r) && (!is_string($r['userHandleB64']) || ($r['userHandleB64'] !== '' && !self::text($r['userHandleB64'],86)))) { throw new ValidationException('userHandleB64 exceeds 86 characters.'); }
+ }
+ /** @param array $body */
+ private static function checkConfirm(array $body): void
+ {
+ if (!self::text($body['ticketId'] ?? null) || (array_key_exists('code',$body) && (!is_string($body['code']) || !preg_match('/^[A-Z0-9]{4}-[A-Z0-9]{4}$/D',$body['code'])))) { throw new ValidationException('Provide the ticket and optional confirmation code.'); }
+ }
+ /**
+ * userVerification and timeoutMs are deprecated ignored inputs; the runner owns them.
+ * @param Issue $body
+ * @return ApiResponse
+ */
+ public function issueChallenge(string $session, array $body = [], ?RequestOptions $options = null): ApiResponse
+ {
+ $path = $this->path($session);
+ self::checkIssue($body);
+ self::budget($body);
+ /** @var ApiResponse */
+ return $this->request('POST', $path.'/challenge', (object)$body, options:($options ?? new RequestOptions())->withoutRetries());
+ }
+ /** @return ApiResponse */
+ public function getChallenge(string $session, string $ticketId, ?RequestOptions $options = null): ApiResponse
+ {
+ /** @var ApiResponse */
+ return $this->request('GET', $this->path($session).'/challenge/'.$this->segment($ticketId), options:$options);
+ }
+ /**
+ * @param Submit $body
+ * @return ApiResponse
+ */
+ public function submitAssertion(string $session, array $body, ?RequestOptions $options = null): ApiResponse
+ {
+ $path = $this->path($session);
+ self::checkSubmit($body);
+ self::budget($body);
+ /** @var ApiResponse */
+ return $this->request('POST', $path.'/assertion', $body, options:($options ?? new RequestOptions())->withoutRetries());
+ }
+ /** @return ApiResponse */
+ public function getConfirmation(string $session, ?RequestOptions $options = null): ApiResponse
+ {
+ /** @var ApiResponse */
+ return $this->request('GET', $this->path($session).'/confirmation', options:$options);
+ }
+ /**
+ * @param Confirm $body
+ * @return ApiResponse
+ */
+ public function confirm(string $session, array $body, ?RequestOptions $options = null): ApiResponse
+ {
+ $path = $this->path($session);
+ self::checkConfirm($body);
+ self::budget($body);
+ /** @var ApiResponse */
+ return $this->request('POST', $path.'/confirmation', $body, options:($options ?? new RequestOptions())->withoutRetries());
+ }
+}
diff --git a/packages/php/src/Resources/Voip.php b/packages/php/src/Resources/Voip.php
index 3c16f138..d380c95f 100644
--- a/packages/php/src/Resources/Voip.php
+++ b/packages/php/src/Resources/Voip.php
@@ -293,6 +293,10 @@ private static function validateReport(array $body): void
/** @param array $body */
private static function validateSettings(array $body): void
{
+ // The API accepts only these keys; simultaneousCallsCoverageExcludedUntil is response-only.
+ if (array_diff(array_keys($body), ['callsEnabled','conferenceMode','inboundRoute','sipTrunkId','sipClaim','hostCloudApiCalls','simultaneousCalls','acknowledgeRisk','expectedRevision']) !== []) {
+ throw new ValidationException('Unknown or read-only call setting.');
+ }
foreach (['callsEnabled','conferenceMode','sipClaim','hostCloudApiCalls','simultaneousCalls','acknowledgeRisk'] as $name) {
if (array_key_exists($name, $body) && !is_bool($body[$name])) {
throw new ValidationException('Call switches must be booleans.');
diff --git a/packages/php/src/VoipModels.php b/packages/php/src/VoipModels.php
index 965df26e..cad734f6 100644
--- a/packages/php/src/VoipModels.php
+++ b/packages/php/src/VoipModels.php
@@ -11,7 +11,7 @@
* @phpstan-type CreatedCallLink array{session:string,token:string,url:string,video:bool}
* @phpstan-type PreviewedCallLink array{session:string,video:bool,creator:Identity,approvalRequired:bool,isAdmin:bool}
* @phpstan-type Participant array{id:string,audioMuted:bool,video:bool,state:'invited'|'ringing'|'connected'|'left',handRaised?:bool,phoneNumber?:string,bsuid?:string,username?:string}
- * @phpstan-type CallSettings array{callsEnabled:bool,conferenceMode:bool,inboundRoute:'clients'|'sip_trunk',sipTrunkId:string|null,sipClaim:bool,hostCloudApiCalls:bool,simultaneousCalls:bool,revision:int,updatedAt:string|null}
+ * @phpstan-type CallSettings array{simultaneousCallsCoverageExcludedUntil:string|null,callsEnabled:bool,conferenceMode:bool,inboundRoute:'clients'|'sip_trunk',sipTrunkId:string|null,sipClaim:bool,hostCloudApiCalls:bool,simultaneousCalls:bool,revision:int,updatedAt:string|null}
* @phpstan-type PermissionLimit array{period:string,maxAllowed:int,used:int,resetsAt:string|null}
* @phpstan-type PermissionAction array{allowed:bool,limits:list}
* @phpstan-type PermissionState array{status:'none'|'temporary'|'permanent'|'revoked',expiresAt:string|null,source:'user_action'|'automatic'|'sync'|'call_refused'|null,updatedAt:string|null,checkedAt:string|null,fresh:bool,actions:array{requestPermission:PermissionAction|null,startCall:PermissionAction|null}|null}
diff --git a/packages/php/tests/passkeys.php b/packages/php/tests/passkeys.php
new file mode 100644
index 00000000..9bce0632
--- /dev/null
+++ b/packages/php/tests/passkeys.php
@@ -0,0 +1,41 @@
+ $c->passkeys->issueChallenge($f['session'],$f['issue'])],
+ ['GET','/challenge/'.rawurlencode($f['ticketId']),null,'ticket',fn($c) => $c->passkeys->getChallenge($f['session'],$f['ticketId'])],
+ ['POST','/assertion',$f['assertion'],'assertionResult',fn($c) => $c->passkeys->submitAssertion($f['session'],$f['assertion'])],
+ ['GET','/confirmation',null,null,fn($c) => $c->passkeys->getConfirmation($f['session'])],
+ ['POST','/confirmation',$f['confirm'],null,fn($c) => $c->passkeys->confirm($f['session'],$f['confirm'])],
+];
+foreach ($cases as [$method,$suffix,$body,$key,$invoke]) {
+ $reply = ['success'=>true,'data'=>$key === null ? ['status'=>'waiting_for_pairing','skipHandoffUX'=>false] : $f[$key]];
+ $history=[]; $client=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse($reply)],$history));
+ check($invoke($client)->data === $reply,'passkey response');
+ $r=$history[0]['request']; check($r->getMethod()===$method && $r->getUri()->getPath()==='/messaging/desk%2F1/passkey'.$suffix,'passkey path');
+ check($r->getHeaderLine('Authorization')==='Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' && $r->hasHeader('Polymorfa-Version'),'passkey headers');
+ check($body === null || json_decode((string)$r->getBody(),true)===$body,'passkey body');
+}
+foreach ($f['statuses'] as $status) {
+ $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['success'=>true,'data'=>['status'=>$status]])],$h));
+ check($c->passkeys->getConfirmation('desk')->data['data']['status']===$status,'passkey outcome');
+}
+foreach (['issueChallenge'=>'issue','submitAssertion'=>'assertion','confirm'=>'confirm'] as $method=>$key) {
+ $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['error'=>['code'=>'future_passkey','message'=>'refused']],503)],$h));
+ raises(fn()=>$c->passkeys->$method('desk',$f[$key],new \Polymorfa\RequestOptions(maxNetworkRetries:3,idempotencyKey:'caller-key')),\Polymorfa\ServerException::class);
+ check(count($h)===1,'passkey write must not repeat');
+}
+
+foreach ($f['errors'] as $status) {
+ $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['error'=>['code'=>'future_passkey','message'=>'refused']],$status)],$h));
+ $e=raises(fn()=>$c->passkeys->confirm('desk',$f['confirm'],new \Polymorfa\RequestOptions(maxNetworkRetries:3,idempotencyKey:'caller')),\Polymorfa\PolymorfaException::class);
+ check($e->status===$status && $e->errorCode==='future_passkey' && count($h)===1,'passkey refusal forwarding');
+}
+$h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([],$h));
+$bad=$f['assertion']; $bad['assertion']['id']=str_repeat('x',1365);
+raises(fn()=>$c->passkeys->submitAssertion('desk',$bad),\Polymorfa\ValidationException::class);
+$bad=$f['assertion']; $bad['assertion']['response']['signatureB64']=str_repeat('x',1048576);
+raises(fn()=>$c->passkeys->submitAssertion('desk',$bad),\Polymorfa\ValidationException::class);
+check(count($h)===0,'passkey bounds refuse before transport');
diff --git a/packages/php/tests/run.php b/packages/php/tests/run.php
index b428227a..2fa56fe9 100644
--- a/packages/php/tests/run.php
+++ b/packages/php/tests/run.php
@@ -270,3 +270,5 @@ function mocked(array $responses, array &$history): HttpClient
require __DIR__.'/official_round2.php';
require __DIR__.'/campaign_webhook_metadata.php';
+
+require __DIR__.'/passkeys.php';
diff --git a/packages/php/tests/voip.php b/packages/php/tests/voip.php
index d83c0d0a..0a394594 100644
--- a/packages/php/tests/voip.php
+++ b/packages/php/tests/voip.php
@@ -58,3 +58,25 @@
$browser = new Polymorfa\MessagingClient(Polymorfa\Credential::clientToken('pmfa_ct_Zml4dHVyZQ'));
raises(fn () => $browser->voip->accept('call_1', ['participant' => 'agent_1']), Polymorfa\ConfigurationException::class);
echo count($cases)." typed Calls REST fixtures and validation/retry boundaries passed.\n";
+
+$coverage = json_decode(file_get_contents(__DIR__.'/../../../contracts/fixtures/calls-coverage-end.candidate.json'), true, flags: JSON_THROW_ON_ERROR);
+foreach ($coverage['cases'] as $case) {
+ foreach (['GET', 'PUT'] as $method) {
+ $history = [];
+ $settings = array_replace($coverage['settings'], ['simultaneousCalls' => $case['on'], 'simultaneousCallsCoverageExcludedUntil' => $case['until']]);
+ $client = new Polymorfa\MessagingClient($credential, http: mocked([jsonResponse(['success' => true, 'data' => $settings])], $history));
+ $result = $method === 'GET' ? $client->voip->retrieveCallSettings('support') : $client->voip->updateCallSettings('support', ['callsEnabled' => false]);
+ check($result->data['data'] === $settings, 'Coverage end exact response');
+ check($result->data['data']['simultaneousCallsCoverageExcludedUntil'] === $case['until'], 'Coverage nullable RFC3339');
+ check($history[0]['request']->getMethod() === $method && $history[0]['request']->getUri()->getPath() === '/platform/sessions/support/call-settings', 'Coverage route');
+ if ($method === 'PUT') check(json_decode((string) $history[0]['request']->getBody(), true) === ['callsEnabled' => false], 'Coverage end not writable');
+ }
+}
+$history = [];
+$client = new Polymorfa\MessagingClient($credential, http: mocked([], $history));
+raises(fn () => $client->voip->updateCallSettings('support', ['simultaneousCallsCoverageExcludedUntil' => null]), Polymorfa\ValidationException::class);
+check($history === [], 'Response-only field is not a call setting');
+foreach ([['simultaneousCallsCoverageExcludedUntil' => null], ['simultaneousCallsCoverageExcludedUntil' => '2026-11-22T12:34:56.789Z'], ['unknownSetting' => true]] as $extra) {
+ raises(fn () => $client->voip->updateCallSettings('support', ['callsEnabled' => false] + $extra), Polymorfa\ValidationException::class);
+}
+check($history === [], 'Response-only and unknown keys are refused beside a setting');
diff --git a/packages/python/README.md b/packages/python/README.md
index d640b700..ee75fb9a 100644
--- a/packages/python/README.md
+++ b/packages/python/README.md
@@ -854,3 +854,59 @@ These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
+
+
+### Call settings exclusion end (Unreleased)
+
+Call-settings GET and PUT responses include the read-only
+`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null.
+While `simultaneousCalls` is on, null means the exclusion has no end yet.
+When it is off, a future timestamp is 30 days after the latest period ended.
+A ban is excluded when the setting was on at any time in the 30 days before it,
+so until that timestamp a new ban is not refunded. At that timestamp the earlier
+period no longer excludes a ban. Null while off
+means no recent period was reported. Neither null nor an elapsed timestamp
+guarantees coverage; all other Ban Insurance conditions still apply.
+
+The server calculates this value. Do not send it in an update or calculate it
+from the current switch alone. This addition is unreleased; source preparation
+does not establish API deployment or package publication.
+
+## Direct passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+```python
+ticket = await client.passkeys.issue_challenge(session)
+await client.passkeys.get_challenge(session, ticket.data["data"]["ticket"])
+await client.passkeys.submit_assertion(session, supplied_assertion)
+state = await client.passkeys.get_confirmation(session)
+
+
+async def confirm_explicitly():
+ # Call only after an explicit decision based on the current state.
+ return await client.passkeys.confirm(session, supplied_confirmation)
+```
diff --git a/packages/python/src/polymorfa/__init__.py b/packages/python/src/polymorfa/__init__.py
index d5804fe0..3f2096df 100644
--- a/packages/python/src/polymorfa/__init__.py
+++ b/packages/python/src/polymorfa/__init__.py
@@ -192,6 +192,19 @@
descriptor_from_media,
download_whatsapp_media_stream,
)
+from .passkeys import (
+ ConfirmPasskey,
+ IssuePasskeyChallenge,
+ PasskeyAssertion,
+ PasskeyAssertionResponse,
+ PasskeyAssertionResult,
+ PasskeyConfirmation,
+ Passkeys,
+ PasskeyStatus,
+ PasskeyTicket,
+ SubmitPasskeyAssertion,
+ UserVerification,
+)
from .policies import graph_transport_headers
from .raw_pagination import PageResult, RawCursorPage
from .request_logs import (
@@ -325,6 +338,7 @@
"CampaignTrackedLinkReport",
"CancelledError",
"ConfigurationError",
+ "ConfirmPasskey",
"ConflictError",
"ConnectionError",
"CreateAudienceFromCampaignRequest",
@@ -349,6 +363,7 @@
"HistorySync",
"ImportConflictSample",
"ImportInvalidSample",
+ "IssuePasskeyChallenge",
"LifecycleCandidate",
"LifecycleEvent",
"LifecycleReady",
@@ -369,6 +384,13 @@
"NotFoundError",
"OutgoingVideoFrame",
"PageResult",
+ "PasskeyAssertion",
+ "PasskeyAssertionResponse",
+ "PasskeyAssertionResult",
+ "PasskeyConfirmation",
+ "PasskeyStatus",
+ "PasskeyTicket",
+ "Passkeys",
"PaymentRequiredError",
"PlatformCampaignRecipientExportParams",
"PlatformCampaignTestSendRequest",
@@ -415,6 +437,7 @@
"StopConfirmationSettings",
"StopConfirmationSummary",
"StreamedEvent",
+ "SubmitPasskeyAssertion",
"TimeoutError",
"TrickleCandidate",
"TypedWebhookEvent",
@@ -422,6 +445,7 @@
"UpdateStopConfirmationSettingsInput",
"UploadGrant",
"UploadLocation",
+ "UserVerification",
"ValidationError",
"VideoFrame",
"VideoTrack",
diff --git a/packages/python/src/polymorfa/client.py b/packages/python/src/polymorfa/client.py
index 05d3885b..c816b39d 100644
--- a/packages/python/src/polymorfa/client.py
+++ b/packages/python/src/polymorfa/client.py
@@ -40,6 +40,7 @@
SecurityIncidents,
SessionBans,
)
+from .passkeys import Passkeys
from .platform_media import Media as PlatformMedia
from .policies import HybridLink, ObservationPolicies, SessionConfigurations
from .quick_replies import QuickReplies, Users
@@ -59,6 +60,7 @@ class AsyncMessagingClient:
history_sync: HistorySync
campaigns: MessagingCampaigns
official_groups: OfficialGroups
+ passkeys: Passkeys
catalogs: Catalogs
cloud_onboarding: CloudOnboarding
testing: Testing
@@ -100,6 +102,7 @@ def __init__(self, credential: Credential, **options: Unpack[ClientOptions]) ->
for name, resource in (
("campaigns", MessagingCampaigns),
("official_groups", OfficialGroups),
+ ("passkeys", Passkeys),
("catalogs", Catalogs),
("cloud_onboarding", CloudOnboarding),
("testing", Testing),
diff --git a/packages/python/src/polymorfa/messaging.py b/packages/python/src/polymorfa/messaging.py
index 7822ecf0..996e2d7f 100644
--- a/packages/python/src/polymorfa/messaging.py
+++ b/packages/python/src/polymorfa/messaging.py
@@ -108,6 +108,20 @@
T = TypeVar("T")
O = RequestOptions()
+# Writable call-setting keys; the API rejects any other key.
+_CALL_SETTING_KEYS = frozenset(
+ (
+ "callsEnabled",
+ "conferenceMode",
+ "inboundRoute",
+ "sipTrunkId",
+ "sipClaim",
+ "hostCloudApiCalls",
+ "simultaneousCalls",
+ "acknowledgeRisk",
+ "expectedRevision",
+ )
+)
def segment(value: str) -> str:
@@ -934,6 +948,9 @@ async def update_call_settings(
or "includeSelfAudio" in body
):
raise ValidationError("Provide a supported call setting.")
+ # The API accepts only these keys; simultaneousCallsCoverageExcludedUntil is response-only.
+ if not set(body) <= _CALL_SETTING_KEYS:
+ raise ValidationError("Unknown or read-only call setting.")
for key in (
"callsEnabled",
"conferenceMode",
diff --git a/packages/python/src/polymorfa/passkeys.py b/packages/python/src/polymorfa/passkeys.py
new file mode 100644
index 00000000..5c508c57
--- /dev/null
+++ b/packages/python/src/polymorfa/passkeys.py
@@ -0,0 +1,193 @@
+"""Direct server passkey operations. Ceremony and ownership remain API-owned."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import replace
+from typing import Literal
+
+from typing_extensions import NotRequired, TypedDict
+
+from .errors import ValidationError
+from .messaging import O, Resource, segment
+from .models import Envelope
+from .transport import ApiResponse, RequestOptions
+
+UserVerification = Literal["required", "preferred", "discouraged"]
+PasskeyStatus = Literal[
+ "none",
+ "challenge_pending",
+ "assertion_sending",
+ "assertion_submitted",
+ "code_available",
+ "confirmation_sending",
+ "waiting_for_pairing",
+ "completed",
+ "failed",
+]
+
+
+class IssuePasskeyChallenge(TypedDict):
+ challengeB64Url: NotRequired[str]
+ ttlSeconds: NotRequired[int]
+ userVerification: NotRequired[UserVerification] # Deprecated, ignored by server.
+ timeoutMs: NotRequired[int] # Deprecated, ignored by server.
+
+
+class PasskeyTicket(TypedDict):
+ version: Literal[2]
+ ticket: str
+ session: str
+ rpId: str
+ challengeB64Url: str
+ userVerification: UserVerification
+ expiresAt: str
+ timeoutMs: NotRequired[int]
+
+
+class PasskeyAssertionResponse(TypedDict):
+ authenticatorDataB64: str
+ clientDataJSONB64: str
+ signatureB64: str
+ userHandleB64: NotRequired[str]
+
+
+class PasskeyAssertion(TypedDict):
+ id: str
+ rawIdB64Url: str
+ response: PasskeyAssertionResponse
+
+
+class SubmitPasskeyAssertion(TypedDict):
+ ticketId: str
+ assertion: PasskeyAssertion
+
+
+class PasskeyAssertionResult(TypedDict):
+ ok: Literal[True]
+ ticket: str
+ session: str
+
+
+class ConfirmPasskey(TypedDict):
+ ticketId: str
+ code: NotRequired[str]
+
+
+class PasskeyConfirmation(TypedDict):
+ status: PasskeyStatus
+ ticketId: NotRequired[str]
+ code: NotRequired[str]
+ skipHandoffUX: NotRequired[bool]
+ error: NotRequired[str]
+
+
+def _text(value: object, limit: int | None = None) -> bool:
+ return (
+ isinstance(value, str)
+ and bool(value)
+ and (limit is None or len(value.encode("utf-16-le")) // 2 <= limit)
+ )
+
+
+def _capture(body: object) -> object:
+ encoded = json.dumps(body, allow_nan=False)
+ if len(encoded.encode()) > 1048576:
+ raise ValidationError("Passkey request exceeds 1 MiB.")
+ return json.loads(encoded)
+
+
+class Passkeys(Resource):
+ def _path(self, session: str) -> str:
+ self._server()
+ return f"/messaging/{segment(session)}/passkey"
+
+ async def issue_challenge(
+ self,
+ session: str,
+ body: IssuePasskeyChallenge | None = None,
+ *,
+ options: RequestOptions = O,
+ ) -> ApiResponse[Envelope[PasskeyTicket]]:
+ path = self._path(session)
+ body = {} if body is None else body
+ if "challengeB64Url" in body and not _text(body["challengeB64Url"]):
+ raise ValidationError("Provide the expected challenge.")
+ if "ttlSeconds" in body and type(body["ttlSeconds"]) is not int:
+ raise ValidationError("ttlSeconds must be an integer.")
+ if "timeoutMs" in body and (type(body["timeoutMs"]) is not int or body["timeoutMs"] <= 0):
+ raise ValidationError("timeoutMs must be a positive integer.")
+ if "userVerification" in body and body["userVerification"] not in (
+ "required",
+ "preferred",
+ "discouraged",
+ ):
+ raise ValidationError("Invalid userVerification.")
+ return await self._request(
+ "POST",
+ path + "/challenge",
+ body=_capture(body),
+ options=replace(options, max_network_retries=0),
+ )
+
+ async def get_challenge(
+ self, session: str, ticket_id: str, *, options: RequestOptions = O
+ ) -> ApiResponse[Envelope[PasskeyTicket]]:
+ return await self._request(
+ "GET", self._path(session) + "/challenge/" + segment(ticket_id), options=options
+ )
+
+ async def submit_assertion(
+ self, session: str, body: SubmitPasskeyAssertion, *, options: RequestOptions = O
+ ) -> ApiResponse[Envelope[PasskeyAssertionResult]]:
+ path = self._path(session)
+ a = body.get("assertion")
+ if (
+ not _text(body.get("ticketId"))
+ or not isinstance(a, dict)
+ or not _text(a.get("id"), 1364)
+ or not _text(a.get("rawIdB64Url"), 1364)
+ ):
+ raise ValidationError("Provide the ticket and bounded credential.")
+ r = a.get("response")
+ if not isinstance(r, dict) or any(
+ not _text(r.get(k))
+ for k in ("authenticatorDataB64", "clientDataJSONB64", "signatureB64")
+ ):
+ raise ValidationError("Provide the assertion response.")
+ if "userHandleB64" in r and (
+ not isinstance(r["userHandleB64"], str)
+ or len(r["userHandleB64"].encode("utf-16-le")) // 2 > 86
+ ):
+ raise ValidationError("userHandleB64 exceeds 86 characters.")
+ return await self._request(
+ "POST",
+ path + "/assertion",
+ body=_capture(body),
+ options=replace(options, max_network_retries=0),
+ )
+
+ async def get_confirmation(
+ self, session: str, *, options: RequestOptions = O
+ ) -> ApiResponse[Envelope[PasskeyConfirmation]]:
+ return await self._request("GET", self._path(session) + "/confirmation", options=options)
+
+ async def confirm(
+ self, session: str, body: ConfirmPasskey, *, options: RequestOptions = O
+ ) -> ApiResponse[Envelope[PasskeyConfirmation]]:
+ path = self._path(session)
+ if not _text(body.get("ticketId")) or (
+ "code" in body
+ and (
+ not isinstance(body["code"], str)
+ or re.fullmatch(r"[A-Z0-9]{4}-[A-Z0-9]{4}", body["code"]) is None
+ )
+ ):
+ raise ValidationError("Provide the ticket and optional confirmation code.")
+ return await self._request(
+ "POST",
+ path + "/confirmation",
+ body=_capture(body),
+ options=replace(options, max_network_retries=0),
+ )
diff --git a/packages/python/src/polymorfa/voip_models.py b/packages/python/src/polymorfa/voip_models.py
index 80197ba2..0aac3ab1 100644
--- a/packages/python/src/polymorfa/voip_models.py
+++ b/packages/python/src/polymorfa/voip_models.py
@@ -43,6 +43,7 @@ class CallParticipant(TypedDict):
class SessionCallSettings(TypedDict):
+ simultaneousCallsCoverageExcludedUntil: str | None
simultaneousCalls: bool
callsEnabled: bool
conferenceMode: bool
diff --git a/packages/python/tests/test_calls_coverage_end.py b/packages/python/tests/test_calls_coverage_end.py
new file mode 100644
index 00000000..77fa20b0
--- /dev/null
+++ b/packages/python/tests/test_calls_coverage_end.py
@@ -0,0 +1,92 @@
+import asyncio
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+
+from polymorfa import AsyncMessagingClient, Credential, ValidationError
+
+FIXTURE = json.loads(
+ (Path(__file__).parents[3] / "contracts/fixtures/calls-coverage-end.candidate.json").read_text()
+)
+
+
+def test_call_settings_coverage_end_response_only():
+ asyncio.run(_coverage_end())
+
+
+async def _coverage_end():
+ for case in FIXTURE["cases"]:
+ for method in ("GET", "PUT"):
+ settings = {
+ **FIXTURE["settings"],
+ "simultaneousCalls": case["on"],
+ "simultaneousCallsCoverageExcludedUntil": case["until"],
+ }
+
+ def handler(request, method=method, settings=settings):
+ assert request.method == method
+ assert request.url.path == "/platform/sessions/support/call-settings"
+ if method == "PUT":
+ assert json.loads(request.content) == {"callsEnabled": False}
+ return httpx.Response(200, json={"success": True, "data": settings})
+
+ async with AsyncMessagingClient(
+ credential=Credential(
+ "organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"
+ ),
+ http_transport=httpx.MockTransport(handler),
+ ) as client:
+ result = (
+ await client.voip.retrieve_call_settings("support")
+ if method == "GET"
+ else await client.voip.update_call_settings("support", {"callsEnabled": False})
+ )
+ assert (
+ result.data["data"]["simultaneousCallsCoverageExcludedUntil"] == case["until"]
+ )
+ assert result.data["data"] == settings
+ with pytest.raises(ValidationError):
+ await client.voip.update_call_settings(
+ "support", {"simultaneousCallsCoverageExcludedUntil": case["until"]}
+ )
+
+
+def test_call_settings_older_response_absence():
+ asyncio.run(_older_response())
+
+
+async def _older_response():
+ async with AsyncMessagingClient(
+ credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"),
+ http_transport=httpx.MockTransport(
+ lambda _: httpx.Response(200, json={"success": True, "data": FIXTURE["settings"]})
+ ),
+ ) as client:
+ assert (
+ "simultaneousCallsCoverageExcludedUntil"
+ not in (await client.voip.retrieve_call_settings("support")).data["data"]
+ )
+
+
+def test_call_settings_reject_response_only_and_unknown_keys():
+ asyncio.run(_reject_extra_keys())
+
+
+async def _reject_extra_keys():
+ sent = []
+ async with AsyncMessagingClient(
+ credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"),
+ http_transport=httpx.MockTransport(
+ lambda request: sent.append(request) or httpx.Response(200, json={})
+ ),
+ ) as client:
+ for extra in (
+ {"simultaneousCallsCoverageExcludedUntil": None},
+ {"simultaneousCallsCoverageExcludedUntil": "2026-11-22T12:34:56.789Z"},
+ {"unknownSetting": True},
+ ):
+ with pytest.raises(ValidationError):
+ await client.voip.update_call_settings("support", {"callsEnabled": False, **extra})
+ assert sent == []
diff --git a/packages/python/tests/test_passkeys.py b/packages/python/tests/test_passkeys.py
new file mode 100644
index 00000000..29fdaba1
--- /dev/null
+++ b/packages/python/tests/test_passkeys.py
@@ -0,0 +1,181 @@
+import asyncio
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+
+from polymorfa import AsyncMessagingClient, Credential, RequestOptions, errors
+
+F = json.loads(
+ (Path(__file__).parents[3] / "contracts/fixtures/passkeys.candidate.json").read_text()
+)
+
+
+def test_passkey_five_wire():
+ asyncio.run(_wire())
+
+
+async def _wire():
+ expected = [
+ ("POST", "/challenge", F["issue"], F["ticket"]),
+ ("GET", "/challenge/ticket%2F%3F", None, F["ticket"]),
+ ("POST", "/assertion", F["assertion"], F["assertionResult"]),
+ ("GET", "/confirmation", None, {"status": "code_available", "skipHandoffUX": False}),
+ ("POST", "/confirmation", F["confirm"], {"status": "waiting_for_pairing"}),
+ ]
+ for i, (method, path, body, reply) in enumerate(expected):
+
+ def handler(request, method=method, path=path, body=body, reply=reply):
+ assert request.method == method
+ assert request.url.raw_path.decode() == "/messaging/desk%2F1/passkey" + path
+ assert request.headers["authorization"].startswith("Bearer pmfa_")
+ assert request.headers["polymorfa-version"] == "2026-09-22"
+ assert (json.loads(request.content) if request.content else None) == body
+ return httpx.Response(200, json={"success": True, "data": reply})
+
+ async with AsyncMessagingClient(
+ credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"),
+ http_transport=httpx.MockTransport(handler),
+ ) as c:
+ p = c.passkeys
+ if i == 0:
+ r = await p.issue_challenge(F["session"], F["issue"])
+ elif i == 1:
+ r = await p.get_challenge(F["session"], F["ticketId"])
+ elif i == 2:
+ r = await p.submit_assertion(F["session"], F["assertion"])
+ elif i == 3:
+ r = await p.get_confirmation(F["session"])
+ else:
+ r = await p.confirm(F["session"], F["confirm"])
+ assert r.data == {"success": True, "data": reply}
+
+
+def test_passkey_writes_are_not_retried():
+ asyncio.run(_once())
+
+
+async def _once():
+ for kind in ("issue", "assertion", "confirm"):
+ count = 0
+
+ def handler(request):
+ nonlocal count
+ count += 1
+ return httpx.Response(
+ 503,
+ json={"error": {"code": "future_passkey_failure", "message": "Unknown outcome"}},
+ )
+
+ async with AsyncMessagingClient(
+ credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"),
+ max_network_retries=3,
+ http_transport=httpx.MockTransport(handler),
+ ) as c:
+ opts = RequestOptions(max_network_retries=3, idempotency_key="caller-key")
+ with pytest.raises(Exception) as error:
+ if kind == "issue":
+ await c.passkeys.issue_challenge("s", F["issue"], options=opts)
+ elif kind == "assertion":
+ await c.passkeys.submit_assertion("s", F["assertion"], options=opts)
+ else:
+ await c.passkeys.confirm("s", F["confirm"], options=opts)
+ assert error.value.code == "future_passkey_failure"
+ assert count == 1
+
+
+def test_passkey_outcomes_refusals_omission_and_bounds():
+ asyncio.run(_boundaries())
+
+
+async def _boundaries():
+ key = Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k")
+ for status in F["statuses"]:
+ async with AsyncMessagingClient(
+ key,
+ http_transport=httpx.MockTransport(
+ lambda r, status=status: httpx.Response(
+ 200, json={"success": True, "data": {"status": status, "skipHandoffUX": False}}
+ )
+ ),
+ ) as c:
+ assert (await c.passkeys.get_confirmation("s")).data["data"] == {
+ "status": status,
+ "skipHandoffUX": False,
+ }
+ for status in F["errors"]:
+ count = 0
+
+ def refuse(request, status=status):
+ nonlocal count
+ count += 1
+ return httpx.Response(
+ status, json={"error": {"code": "future_passkey", "message": "refused"}}
+ )
+
+ async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(refuse)) as c:
+ with pytest.raises(errors.PolymorfaError) as e:
+ await c.passkeys.confirm(
+ "s",
+ F["confirm"],
+ options=RequestOptions(max_network_retries=3, idempotency_key="caller"),
+ )
+ assert e.value.status == status and e.value.code == "future_passkey" and count == 1
+ for mode in ("non-json", "network"):
+ for kind in ("issue", "assertion", "confirm"):
+ count = 0
+
+ def broken(request, mode=mode):
+ nonlocal count
+ count += 1
+ if mode == "network":
+ raise httpx.ReadError("connection lost after upload", request=request)
+ return httpx.Response(
+ 502, text="unavailable", headers={"content-type": "text/html"}
+ )
+
+ async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(broken)) as c:
+ expected = errors.ConnectionError if mode == "network" else errors.ServerError
+ with pytest.raises(expected):
+ o = RequestOptions(max_network_retries=3, idempotency_key="caller")
+ if kind == "issue":
+ await c.passkeys.issue_challenge("s", F["issue"], options=o)
+ elif kind == "assertion":
+ await c.passkeys.submit_assertion("s", F["assertion"], options=o)
+ else:
+ await c.passkeys.confirm("s", F["confirm"], options=o)
+ assert count == 1
+ bodies = []
+
+ def capture(request):
+ bodies.append(json.loads(request.content))
+ return httpx.Response(200, json={"success": True, "data": {"status": "none"}})
+
+ async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(capture)) as c:
+ await c.passkeys.issue_challenge("s")
+ await c.passkeys.confirm("s", {"ticketId": "ticket"})
+ assert bodies == [{}, {"ticketId": "ticket"}]
+ bad = json.loads(json.dumps(F["assertion"]))
+ bad["assertion"]["id"] = "x" * 1365
+ with pytest.raises(errors.ValidationError):
+ await c.passkeys.submit_assertion("s", bad)
+ bad["assertion"]["id"] = "x" * 1364
+ bad["assertion"]["rawIdB64Url"] = "x" * 1364
+ bad["assertion"]["response"]["userHandleB64"] = "x" * 86
+ await c.passkeys.submit_assertion("s", bad)
+ assert bodies[-1] == bad
+ bad["assertion"]["response"]["signatureB64"] = "x" * 1048576
+ with pytest.raises(errors.ValidationError):
+ await c.passkeys.submit_assertion("s", bad)
+ assert len(bodies) == 3
+ assert not hasattr(c.passkeys, "verify") and not hasattr(
+ c.passkeys, "submit_quicklink_passkey"
+ )
+ async with AsyncMessagingClient(
+ Credential("client_token", "pmfa_ct_Zml4dHVyZQ"),
+ http_transport=httpx.MockTransport(capture),
+ ) as c:
+ with pytest.raises(errors.ConfigurationError):
+ await c.passkeys.get_confirmation("s")
+ assert len(bodies) == 3
diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml
index eec72107..b9bd178d 100644
--- a/packages/rust/Cargo.toml
+++ b/packages/rust/Cargo.toml
@@ -57,5 +57,5 @@ url = "2"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
-tokio = { version = "1", features = ["full"] }
+tokio = { version = "1", features = ["full", "test-util"] }
diff --git a/packages/rust/README.md b/packages/rust/README.md
index 00f3db91..d54a2916 100644
--- a/packages/rust/README.md
+++ b/packages/rust/README.md
@@ -928,3 +928,67 @@ These are verified-event handling fragments, not complete receivers. Preserve
whole-body signature verification before decoding and deduplicate batch members.
See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md).
Package publication and matching API environment acceptance remain separate gates.
+
+
+### Call settings exclusion end (Unreleased)
+
+Call-settings GET and PUT responses include the read-only
+`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null.
+While `simultaneousCalls` is on, null means the exclusion has no end yet.
+When it is off, a future timestamp is 30 days after the latest period ended.
+A ban is excluded when the setting was on at any time in the 30 days before it,
+so until that timestamp a new ban is not refunded. At that timestamp the earlier
+period no longer excludes a ban. Null while off
+means no recent period was reported. Neither null nor an elapsed timestamp
+guarantees coverage; all other Ban Insurance conditions still apply.
+
+The server calculates this value. Do not send it in an update or calculate it
+from the current switch alone. This addition is unreleased; source preparation
+does not establish API deployment or package publication.
+
+Rust response struct literals must include
+`simultaneous_calls_coverage_excluded_until: None` or a timestamp.
+
+## Direct passkey relay (unreleased)
+
+Use server credentials with `sessions:manage` for every operation, including reads.
+The API also checks live Number ownership, direct-pairing access, relay enrollment
+and the current attempt. These methods accept ceremony data supplied by an
+external participant. They do not run WebAuthn, store credentials, poll, or confirm
+a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded.
+
+Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server
+owns ceremony options and ignores the deprecated `userVerification` and
+`timeoutMs` request fields. An optional expected `challengeB64Url` must match the
+current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged;
+do not convert between base64 and base64url. Omit optional fields when absent.
+
+Challenge issuance, assertion submission and explicit confirmation each make one
+attempt, even with caller retry options or an idempotency key. If the outcome is
+uncertain, read current challenge/confirmation state and reconcile it before a new
+write. Assertion acceptance is not pairing completion. Forwarded API failures keep
+their status and open error code; the SDK does not invent passkey error codes.
+
+The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16
+units and optional user handles allow 86. Other opaque assertion fields share the
+body limit. The API validates encodings, decoded bounds and challenge binding.
+
+The matching API source is accepted. Package qualification and publication are
+still required before using a released package.
+
+```rust,no_run
+# async fn supplied_steps(client: &polymorfa_sdk::MessagingClient, session: &str, supplied_assertion: &polymorfa_sdk::passkeys::SubmitPasskeyAssertion, supplied_confirmation: &polymorfa_sdk::passkeys::ConfirmPasskey) -> polymorfa_sdk::Result<()> {
+use polymorfa_sdk::{passkeys::IssuePasskeyChallenge, RequestOptions};
+let ticket = client.passkeys().issue_challenge(session, &IssuePasskeyChallenge::default(), RequestOptions::default()).await?;
+client.passkeys().get_challenge(session, &ticket.data.data.ticket, RequestOptions::default()).await?;
+client.passkeys().submit_assertion(session, supplied_assertion, RequestOptions::default()).await?;
+let state = client.passkeys().get_confirmation(session, RequestOptions::default()).await?;
+// This function is called only after the caller's explicit decision.
+async fn confirm_explicitly(client: &polymorfa_sdk::MessagingClient, session: &str, supplied: &polymorfa_sdk::passkeys::ConfirmPasskey) -> polymorfa_sdk::Result<()> {
+ client.passkeys().confirm(session, supplied, RequestOptions::default()).await?;
+ Ok(())
+}
+# let _ = (state, supplied_confirmation);
+# Ok(())
+# }
+```
diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs
index 94baa589..c63a5673 100644
--- a/packages/rust/src/lib.rs
+++ b/packages/rust/src/lib.rs
@@ -42,6 +42,7 @@ pub mod operations;
pub mod organization;
pub mod organization_controls;
pub mod pagination;
+pub mod passkeys;
pub mod platform;
pub mod platform_sessions;
pub mod policies;
diff --git a/packages/rust/src/messaging.rs b/packages/rust/src/messaging.rs
index 9468588c..2bb998be 100644
--- a/packages/rust/src/messaging.rs
+++ b/packages/rust/src/messaging.rs
@@ -10,6 +10,10 @@ pub struct MessagingClient {
pub(crate) http: HttpTransport,
}
impl MessagingClient {
+ pub fn passkeys(&self) -> crate::passkeys::Passkeys<'_> {
+ crate::passkeys::Passkeys(&self.http)
+ }
+
pub fn new(credential: Credential, options: ClientOptions) -> Result {
Ok(Self {
http: HttpTransport::new(credential, options)?,
diff --git a/packages/rust/src/passkeys.rs b/packages/rust/src/passkeys.rs
new file mode 100644
index 00000000..c92aeb7e
--- /dev/null
+++ b/packages/rust/src/passkeys.rs
@@ -0,0 +1,229 @@
+//! Direct server passkey operations. No ceremony, polling, or automatic confirmation.
+use crate::{
+ models::SuccessEnvelope,
+ transport::{encode, HttpTransport},
+ ApiResponse, Error, ErrorKind, RequestOptions, Result,
+};
+use reqwest::Method;
+use serde::{Deserialize, Serialize};
+fn invalid(message: &str) -> Error {
+ Error::local(ErrorKind::Validation, message, "invalid_parameter")
+}
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct IssuePasskeyChallenge {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub challenge_b64_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ttl_seconds: Option,
+ /// Deprecated and ignored by the server; the runner owns this value.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_verification: Option,
+ /// Deprecated and ignored by the server. Milliseconds.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub timeout_ms: Option,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PasskeyTicket {
+ pub version: u8,
+ pub ticket: String,
+ pub session: String,
+ pub rp_id: String,
+ pub challenge_b64_url: String,
+ pub user_verification: String,
+ pub expires_at: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub timeout_ms: Option,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PasskeyAssertionResponse {
+ pub authenticator_data_b64: String,
+ #[serde(rename = "clientDataJSONB64")]
+ pub client_data_json_b64: String,
+ pub signature_b64: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_handle_b64: Option,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PasskeyAssertion {
+ pub id: String,
+ pub raw_id_b64_url: String,
+ pub response: PasskeyAssertionResponse,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SubmitPasskeyAssertion {
+ pub ticket_id: String,
+ pub assertion: PasskeyAssertion,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PasskeyAssertionResult {
+ pub ok: bool,
+ pub ticket: String,
+ pub session: String,
+}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConfirmPasskey {
+ pub ticket_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub code: Option,
+}
+/// Known status values; unknown response strings remain available for forward compatibility.
+pub const PASSKEY_STATUSES: [&str; 9] = [
+ "none",
+ "challenge_pending",
+ "assertion_sending",
+ "assertion_submitted",
+ "code_available",
+ "confirmation_sending",
+ "waiting_for_pairing",
+ "completed",
+ "failed",
+];
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PasskeyConfirmation {
+ pub status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ticket_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub code: Option,
+ #[serde(skip_serializing_if = "Option::is_none", rename = "skipHandoffUX")]
+ pub skip_handoff_ux: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+pub struct Passkeys<'a>(pub(crate) &'a HttpTransport);
+fn text(s: &str, max: usize) -> bool {
+ !s.is_empty() && s.encode_utf16().count() <= max
+}
+impl Passkeys<'_> {
+ fn path(&self, session: &str) -> Result {
+ self.0.server()?;
+ if session.is_empty() {
+ return Err(invalid("Provide a Number ID."));
+ }
+ Ok(format!("/messaging/{}/passkey", encode(session)))
+ }
+ async fn write(
+ &self,
+ path: String,
+ body: &B,
+ mut options: RequestOptions,
+ ) -> Result>> {
+ let bytes = serde_json::to_vec(body).map_err(|_| invalid("Invalid passkey JSON."))?;
+ if bytes.len() > 1048576 {
+ return Err(invalid("Passkey request exceeds 1 MiB."));
+ }
+ options.max_network_retries = Some(0);
+ self.0
+ .request(Method::POST, &path, &[], Some(body), options)
+ .await
+ }
+ pub async fn issue_challenge(
+ &self,
+ session: &str,
+ body: &IssuePasskeyChallenge,
+ options: RequestOptions,
+ ) -> Result>> {
+ let path = self.path(session)?;
+ if body
+ .challenge_b64_url
+ .as_ref()
+ .is_some_and(|s| s.is_empty())
+ || body.timeout_ms == Some(0)
+ || body
+ .user_verification
+ .as_ref()
+ .is_some_and(|s| !matches!(s.as_str(), "required" | "preferred" | "discouraged"))
+ {
+ return Err(invalid("Invalid challenge options."));
+ }
+ self.write(path + "/challenge", body, options).await
+ }
+ pub async fn get_challenge(
+ &self,
+ session: &str,
+ ticket: &str,
+ options: RequestOptions,
+ ) -> Result>> {
+ let path = self.path(session)?;
+ if ticket.is_empty() {
+ return Err(invalid("Provide a ticket."));
+ }
+ self.0
+ .request::<_, ()>(
+ Method::GET,
+ &format!("{path}/challenge/{}", encode(ticket)),
+ &[],
+ None,
+ options,
+ )
+ .await
+ }
+ pub async fn submit_assertion(
+ &self,
+ session: &str,
+ body: &SubmitPasskeyAssertion,
+ options: RequestOptions,
+ ) -> Result>> {
+ let path = self.path(session)?;
+ let a = &body.assertion;
+ let r = &a.response;
+ if body.ticket_id.is_empty()
+ || !text(&a.id, 1364)
+ || !text(&a.raw_id_b64_url, 1364)
+ || r.authenticator_data_b64.is_empty()
+ || r.client_data_json_b64.is_empty()
+ || r.signature_b64.is_empty()
+ || r.user_handle_b64
+ .as_ref()
+ .is_some_and(|s| s.encode_utf16().count() > 86)
+ {
+ return Err(invalid("Provide the ticket and bounded assertion."));
+ }
+ self.write(path + "/assertion", body, options).await
+ }
+ pub async fn get_confirmation(
+ &self,
+ session: &str,
+ options: RequestOptions,
+ ) -> Result>> {
+ self.0
+ .request::<_, ()>(
+ Method::GET,
+ &(self.path(session)? + "/confirmation"),
+ &[],
+ None,
+ options,
+ )
+ .await
+ }
+ pub async fn confirm(
+ &self,
+ session: &str,
+ body: &ConfirmPasskey,
+ options: RequestOptions,
+ ) -> Result>> {
+ let path = self.path(session)?;
+ if body.ticket_id.is_empty()
+ || body.code.as_ref().is_some_and(|s| {
+ s.len() != 9
+ || s.as_bytes()[4] != b'-'
+ || s.bytes()
+ .enumerate()
+ .any(|(i, c)| i != 4 && !c.is_ascii_uppercase() && !c.is_ascii_digit())
+ })
+ {
+ return Err(invalid(
+ "Provide the ticket and optional confirmation code.",
+ ));
+ }
+ self.write(path + "/confirmation", body, options).await
+ }
+}
diff --git a/packages/rust/src/transport.rs b/packages/rust/src/transport.rs
index 92aa0f38..0fa42c50 100644
--- a/packages/rust/src/transport.rs
+++ b/packages/rust/src/transport.rs
@@ -665,6 +665,8 @@ impl HttpTransport {
let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
return Err(Error::response(&value, metadata));
}
+ #[cfg(test)]
+ admission_test::observe_admission(attempt);
return Ok((response, metadata));
}
}
@@ -992,3 +994,7 @@ fn metadata(response: &reqwest::Response, attempts: u32) -> ResponseMetadata {
headers,
}
}
+
+#[cfg(test)]
+#[path = "transport_admission_test.rs"]
+mod admission_test;
diff --git a/packages/rust/src/transport_admission_test.rs b/packages/rust/src/transport_admission_test.rs
new file mode 100644
index 00000000..09c9bb6c
--- /dev/null
+++ b/packages/rust/src/transport_admission_test.rs
@@ -0,0 +1,155 @@
+//! Phase-observed regression for the admitted response-body timeout. Test builds only.
+//!
+//! The request timeout covers connect, request write and response headers, and an
+//! eligible keyed attempt retries when it expires there. This test needs the body
+//! phase, so it waits for the client's own admission (successful headers and
+//! metadata in `open_internal`) before letting the unchanged 30 ms timer run.
+use crate::{models::Query, ClientOptions, Credential, ErrorKind, MessagingClient, RequestOptions};
+use std::{cell::RefCell, sync::mpsc, time::Duration};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+thread_local! {
+ static ADMITTED: RefCell