diff --git a/contracts/official-api-round2.json b/contracts/official-api-round2.json new file mode 100644 index 00000000..c3573741 --- /dev/null +++ b/contracts/official-api-round2.json @@ -0,0 +1,108 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f", + "family": "messaging", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "6d5b5477a86a85504f921933bf7b26f2c47506e21380fe73c8a5ff1002163b28", + "published": false, + "note": "Prepared beta counterparts. Exact merged sources below remain authoritative. No staging/production acceptance or package publication. Reconcile full release source, spec coverage and all feature pins before publication. Private Console routes are excluded.", + "items": [ + { + "item": "OA-03", + "mergeSha": "682b9980a19de784b9ada565a97440a8b7fb05fb", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "cfa88b9f4aea0cd2d16347d33818f6a3f5870fa0cb376dd6a97103e6b677db2a", + "releasePath": "internal/docs/releases/2026-10-11-cloud-onboarding-readiness.md", + "releaseSha256": "1cb94749b857cce638b81aecbf34f5dfaf1079fca94bdb1632e24069b47463d0", + "snapshotPath": "contracts/official-api-round2/oa-03.json", + "snapshotSha256": "62641435f1d670607d5b91e8363f42fdc45dc7d669c2e3a8c461c2a01abe337c", + "operationIds": ["getQuickLink", "cancelQuickLink"] + }, + { + "item": "OA-16", + "mergeSha": "7391c22e50d6e238771f3428c22611e100f9a13e", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "04644a0e0fd11b81d457a7f5efb55648567e382953aa2cb93fc7f7250aeff16a", + "releasePath": "internal/docs/releases/2026-10-11-cloud-catalog-sync.md", + "releaseSha256": "dfc1a16d31be23926a1440b5ded807251ba9ca4a7b732c99152ffd0d744aa30f", + "snapshotPath": "contracts/official-api-round2/oa-16.json", + "snapshotSha256": "2bc4fbba821e9c3c90b7a9f632aace0360edbcaf8a1685b9b6a4d9387b5d305d", + "operationIds": [ + "listCloudCatalogs", + "getCloudCatalog", + "syncCloudCatalog", + "listCloudCatalogProducts" + ] + }, + { + "item": "OA-17", + "mergeSha": "24ac44aad59715a3f32dcbee2f4fe5cb07bbda87", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "5ce4bfda4d9ac8ca29b72a9eff6cf009cc0ee0a8be0167e820d3c851ca3e6f8e", + "releasePath": "internal/docs/releases/2026-10-11-cloud-marketing-messages.md", + "releaseSha256": "088feba9c5dbb933f7a8f159ea7f9f1c6489fbcf70da82277c1f3bd7f04695f4", + "snapshotPath": "contracts/official-api-round2/oa-17.json", + "snapshotSha256": "84f218fa062e92c5792e9f075d1049b26f314634412da2db8b49e195d53d3dee", + "operationIds": ["getMarketingMessagesStatus"] + }, + { + "item": "OA-18", + "mergeSha": "e092ffc559b0bd814931560059e07983c6915f66", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "7520f63ea6f8b6ca02465b4548c44b5001b0b0b0fadde331d06c4b497bc0153c", + "releasePath": "internal/docs/releases/2026-10-16-oa18-parent-bsuid-block.md", + "releaseSha256": "aea935f32779f2c4d32ca95427746203b5d51cca63f07e4d5454906d6acbe28a", + "snapshotPath": "contracts/official-api-round2/oa-18.json", + "snapshotSha256": "a381304c948807df9ec410617e93ac827073978f091708aac134fe23b7c68f0d", + "operationIds": ["blockContact", "unblockContact"] + }, + { + "item": "OA-21", + "mergeSha": "ceb2d0227e5d0aa21e390508d9437e088794da1f", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "9d8faf13be970bee800ad15e308c130bc4ae8703865ae99278b9ae315cc27638", + "releasePath": "internal/docs/releases/2026-10-01-official-groups.md", + "releaseSha256": "72efd0f27256dd1c7ee04a26e325b6a9051c4a8453d0b554dbce14241fb662d4", + "snapshotPath": "contracts/official-api-round2/oa-21.json", + "snapshotSha256": "ec42f4a70d6d02803eb31864438668f19307b97486a71dcb0c38ac5cc92b13f6", + "operationIds": ["setOfficialGroupPicture"] + }, + { + "item": "OA-30", + "mergeSha": "d1985700bf1738fe2c4fd55b17a1e6d7fd328453", + "sourcePath": "apps/api/docs/openapi.management.json", + "sourceSha256": "e28256daac1e8efd84998d7399b74c3200623c19ac807a4b0730523a667976ff", + "releasePath": "internal/docs/releases/2026-10-16-webhook-batching.md", + "releaseSha256": "98c411776b87bcbf6fe6a77b7657472ce1ced305a427e939946454a2f8acce37", + "snapshotPath": "contracts/official-api-round2/oa-30.json", + "snapshotSha256": "b6885a304b5d29d14e06c7b6281f1b55d04041f44a288f6754355b8ae67d01f4", + "operationIds": [ + "listOrganizationWebhooks", + "createOrganizationWebhook", + "getOrganizationWebhook", + "updateOrganizationWebhook", + "deleteOrganizationWebhook", + "rotateOrganizationWebhookSecret", + "testOrganizationWebhook", + "listProjectWebhooks", + "createProjectWebhook", + "getProjectWebhook", + "updateProjectWebhook", + "deleteProjectWebhook", + "rotateProjectWebhookSecret", + "testProjectWebhook" + ] + } + ], + "errorCodes": [ + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "marketing_disabled_on_cloud_api", + "parent_bsuid_not_supported", + "hybrid_group_linked_devices_unavailable", + "catalog_cache_full", + "catalog_sync_cooldown" + ], + "checkedDev": "dafb7cb4e4efbcdcbd04af206f1ef6d47c209379" +} diff --git a/contracts/official-api-round2/oa-03.json b/contracts/official-api-round2/oa-03.json new file mode 100644 index 00000000..3d9cbf76 --- /dev/null +++ b/contracts/official-api-round2/oa-03.json @@ -0,0 +1,816 @@ +{ + "paths": { + "/messaging/quicklinks/{id}": { + "get": { + "operationId": "getQuickLink", + "tags": ["QuickLink"], + "summary": "Read QuickLink status", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "quicklink:manage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Current QuickLink status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuickLinkStatusResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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" + } + } + }, + "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" + } + } + } + }, + "x-mint": { + "content": "quicklink:manage" + }, + "description": "**Required scope:** `quicklink:manage`" + }, + "delete": { + "operationId": "cancelQuickLink", + "tags": ["QuickLink"], + "summary": "Cancel a QuickLink", + "description": "**Required scope:** `quicklink:manage`\n\nCancels pending onboarding. Initial Number cleanup is accepted asynchronously and completes only after runtime teardown. Supplementary and reauthorization links cancel only their pending attempts; the existing Number remains. Accepted provider work must finish before cancellation.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "quicklink:manage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Cancelled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "message": { + "type": "string" + } + }, + "required": ["success", "message"] + } + } + }, + "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" + } + } + }, + "202": { + "description": "QuickLink invalidated; Number cleanup pending", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "message": { + "type": "string" + } + }, + "required": ["success", "message"] + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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" + } + } + }, + "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" + } + } + } + }, + "x-mint": { + "content": "quicklink:manage" + } + } + } + }, + "components": { + "schemas": { + "QuickLinkStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "purpose": { + "type": "string", + "enum": ["initial", "add_connection", "reauthorization"] + }, + "connectionGoal": { + "type": "string", + "enum": ["single", "hybrid"] + }, + "addConnection": { + "type": "string", + "enum": ["linked_devices", "official_api"] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "opened", + "linked", + "connected", + "failed", + "cancelled" + ], + "description": "`linked`: for Linked Devices, the device is linked and the recipient has not confirmed; for Meta Cloud API, the number is connected to Meta and is still being made ready to send. For Meta Cloud API, `connected` requires every readiness check to pass, a successful runtime connection, and the recipient's confirmation. `failed` carries the reason in `errorCode`." + }, + "session": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true + }, + "openedAt": { + "type": "string", + "nullable": true + }, + "connectedAt": { + "type": "string", + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true, + "description": "Connected phone in E.164 once the link is connected." + }, + "errorCode": { + "type": "string", + "nullable": true + }, + "hybridPhase": { + "type": "string", + "nullable": true, + "enum": [ + "cloud_setup", + "linked_pairing", + "repair_linked", + "ready", + null + ] + }, + "onboarding": { + "type": "object", + "nullable": true, + "properties": { + "stage": { + "type": "string" + }, + "connection": { + "type": "string", + "nullable": true + }, + "coexistence": { + "type": "boolean", + "nullable": true + }, + "contactsSync": { + "type": "string" + }, + "historySync": { + "type": "string" + }, + "historyProgress": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "errorCode": { + "type": "string", + "nullable": true + }, + "sync": { + "$ref": "#/components/schemas/CloudSyncStatus" + } + }, + "required": [ + "stage", + "connection", + "coexistence", + "contactsSync", + "historySync", + "historyProgress", + "errorCode", + "sync" + ], + "description": "Durable connection and sync progress; omitted when the onboarding service is unavailable, null when no record exists. Sync request and delivery evidence are independent of the connection stage." + } + }, + "required": [ + "id", + "purpose", + "connectionGoal", + "status", + "session", + "expiresAt", + "openedAt", + "connectedAt", + "phone", + "errorCode", + "hybridPhase" + ] + }, + "CloudSyncStatus": { + "type": "object", + "properties": { + "contacts": { + "type": "object", + "properties": { + "request": { + "type": "string", + "enum": [ + "not_applicable", + "not_requested", + "pending", + "requesting", + "accepted", + "declined", + "unknown" + ], + "description": "Accepted means Meta returned a request receipt; it does not prove all contacts were delivered. Unknown means a one-time request may have been accepted without a saved receipt." + }, + "receiptRecorded": { + "type": "boolean" + } + }, + "required": ["request", "receiptRecorded"] + }, + "history": { + "type": "object", + "properties": { + "request": { + "type": "string", + "enum": [ + "not_applicable", + "not_requested", + "pending", + "requesting", + "accepted", + "declined", + "unknown" + ] + }, + "receiptRecorded": { + "type": "boolean" + }, + "delivery": { + "type": "string", + "enum": [ + "not_applicable", + "not_requested", + "unconfirmed", + "partial", + "complete", + "declined" + ], + "description": "Partial and complete describe the latest history progress reported by Meta. Unconfirmed does not mean that no history exists." + } + }, + "required": ["request", "receiptRecorded", "delivery"] + } + }, + "required": ["contacts", "history"] + }, + "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_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", + "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"] + }, + "QuickLinkStatusResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/QuickLinkStatus" + } + }, + "required": ["success", "data"] + } + } + } +} diff --git a/contracts/official-api-round2/oa-16.json b/contracts/official-api-round2/oa-16.json new file mode 100644 index 00000000..d907e30c --- /dev/null +++ b/contracts/official-api-round2/oa-16.json @@ -0,0 +1,2003 @@ +{ + "paths": { + "/messaging/{session}/catalogs": { + "get": { + "operationId": "listCloudCatalogs", + "tags": ["Catalogs"], + "summary": "List synced catalogs for an Official API Number", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `business:read`\n\nReturns the catalogs this project has synced for the WhatsApp Business Account of the Number's active Official connection, with each sync's state. It does not call Meta.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "business:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Synced catalogs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCloudCatalogsResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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" + } + } + }, + "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" + } + } + }, + "503": { + "description": "Catalog access or the Number's WhatsApp Business Account is unavailable", + "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": "Meta Cloud API business:read" + }, + "x-protocols": ["Meta Cloud API"] + } + }, + "/messaging/{session}/catalogs/{catalogId}": { + "get": { + "operationId": "getCloudCatalog", + "tags": ["Catalogs"], + "summary": "Get a catalog's sync state", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "business:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[0-9]{1,32}$", + "description": "Meta catalog ID" + }, + "required": true, + "name": "catalogId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Sync state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCloudCatalogResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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" + } + } + }, + "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" + } + } + }, + "503": { + "description": "Catalog access or the Number's WhatsApp Business Account is unavailable", + "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": "Meta Cloud API business:read" + }, + "x-protocols": ["Meta Cloud API"], + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `business:read`" + } + }, + "/messaging/{session}/catalogs/{catalogId}/sync": { + "post": { + "operationId": "syncCloudCatalog", + "tags": ["Catalogs"], + "summary": "Sync a linked catalog from Meta", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `business:read`\n\nReads every product of a catalog linked to the Number's WhatsApp Business Account, up to 5,000, and replaces the stored copy only when the read is complete. A failed or incomplete read leaves the stored products unchanged. One sync per catalog can start every 2 minutes.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "business:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[0-9]{1,32}$", + "description": "Meta catalog ID" + }, + "required": true, + "name": "catalogId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The stored products were replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncCloudCatalogResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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": "The Number was not found, or Meta does not list the catalog for this WhatsApp Business Account (`catalog_not_linked`). Stored products for it are removed.", + "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": "A newer sync replaced this one (`catalog_sync_superseded`)", + "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" + } + } + }, + "422": { + "description": "The catalog exceeds 5,000 products (`catalog_too_large`), or the project already caches 100 catalogs (`catalog_cache_full`). Stored products are unchanged.", + "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" + } + } + }, + "429": { + "description": "A sync for this catalog started less than 2 minutes ago (`catalog_sync_cooldown`). Retry after `retryAfter` seconds.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "The request limit was reached. Follow Retry-After when supplied.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#rate-limit-exceeded" + } + } + }, + "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": "Meta refused or did not complete the read (`catalog_sync_incomplete`). Stored products are unchanged.", + "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": "Catalog access or the Number's WhatsApp Business Account is unavailable", + "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": "Meta Cloud API business:read" + }, + "x-protocols": ["Meta Cloud API"] + } + }, + "/messaging/{session}/catalogs/{catalogId}/products": { + "get": { + "operationId": "listCloudCatalogProducts", + "tags": ["Catalogs"], + "summary": "List stored products of a synced catalog", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `business:read`\n\nPages through the products stored by the last complete sync, in product ID order. Check `catalog.stale` and sync again when you need current data. It does not call Meta.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "business:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[0-9]{1,32}$", + "description": "Meta catalog ID" + }, + "required": true, + "name": "catalogId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Page size, 1-100 (default 50)" + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,256}$", + "description": "`nextCursor` from the previous page" + }, + "required": false, + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "One page of stored products", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCloudCatalogProductsResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/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": "The catalog changed after the previous page (`catalog_snapshot_changed`). Restart without a cursor.", + "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" + } + } + }, + "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" + } + } + }, + "503": { + "description": "Catalog access or the Number's WhatsApp Business Account is unavailable", + "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": "Meta Cloud API business:read" + }, + "x-protocols": ["Meta Cloud API"] + } + } + }, + "components": { + "schemas": { + "CloudMessagePayload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "whatsapp_ids": { + "$ref": "#/components/schemas/WhatsAppMessageIds" + }, + "whatsapp_id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Temporary singular provider reference for older consumers. Use whatsapp_ids for transport-specific references.", + "deprecated": true + }, + "conversation": { + "$ref": "#/components/schemas/ConversationRef" + }, + "timestamp": { + "type": "string" + }, + "type": { + "type": "string" + }, + "senderName": { + "type": "string" + }, + "nativeFlowResponse": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "paramsJson": { + "type": "string", + "minLength": 1, + "maxLength": 262144 + }, + "version": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["name", "paramsJson"] + }, + "replyChoice": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["button", "list"], + "description": "Reply control the contact used: a reply or quick-reply button, or a list row." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "ID you assigned to the selected button or list row. It is never the visible label, except for a template quick-reply button sent without a payload, where WhatsApp returns the button text." + } + }, + "required": ["kind", "id"], + "description": "Present when the contact selects a reply button or list row." + }, + "parentMessageId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "WhatsApp ID of the message this one quotes, when WhatsApp supplies one. It is not necessarily a message you sent." + }, + "interactive": { + "type": "object", + "additionalProperties": {} + }, + "order": { + "$ref": "#/components/schemas/CloudOrder" + }, + "referral": { + "type": "object", + "properties": { + "source_type": { + "type": "string" + }, + "source_id": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "ctwa_clid": { + "type": "string" + } + }, + "description": "Meta-supplied Click-to-WhatsApp entry point. Does not establish a conversion or payment." + } + }, + "required": ["id", "whatsapp_ids", "conversation", "timestamp", "type"] + }, + "HistoryMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "whatsapp_ids": { + "$ref": "#/components/schemas/WhatsAppMessageIds" + }, + "whatsapp_id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Temporary singular provider reference for older consumers. Use whatsapp_ids for transport-specific references.", + "deprecated": true + }, + "conversation": { + "$ref": "#/components/schemas/HistoryConversation" + }, + "direction": { + "type": "string", + "enum": ["inbound", "outbound"] + }, + "fromMe": { + "type": "boolean", + "description": "True when the Number sent the message, including from another linked device." + }, + "type": { + "type": "string", + "description": "Message type as delivered in `message.received`, for example `text` or `image`." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When WhatsApp reported the message." + }, + "pushName": { + "type": "string" + }, + "text": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "ptt": { + "type": "boolean" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "displayName": { + "type": "string" + }, + "title": { + "type": "string" + }, + "reaction": { + "type": "string" + }, + "reactionTo": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "edited": { + "type": "boolean" + }, + "unavailable": { + "type": "boolean" + }, + "unavailableReason": { + "type": "string" + }, + "pollOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["name", "hash"] + } + }, + "order": { + "allOf": [ + { + "$ref": "#/components/schemas/CloudOrder" + }, + { + "description": "Official API `order` messages: the cart as Meta reported it, when every line is readable." + } + ] + }, + "media": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HistoryMedia" + }, + "description": "Downloadable media for image, video, audio, document, and sticker messages. Empty when no copy is available." + }, + "mediaRetrieval": { + "$ref": "#/components/schemas/HistoryMediaRetrieval" + } + }, + "required": [ + "id", + "whatsapp_ids", + "conversation", + "direction", + "fromMe", + "type", + "timestamp" + ] + }, + "HistoryMediaRetrieval": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "pending", + "stored", + "unavailable", + "expired", + "too_large", + "unsupported_type", + "failed", + "cancelled" + ], + "description": "`pending`: the copy is not stored yet. `stored`: `media` lists the copy. Every other state is final and no copy will be stored." + }, + "reason": { + "type": "string", + "description": "Why a final state other than `stored` was reached, for example `provider_media_not_found` or `media_too_large`.", + "example": "provider_media_expired" + } + }, + "required": ["state"], + "description": "Official API media only: whether a copy of the media was stored with this message." + }, + "HistoryMedia": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Media ID. Download the bytes from `url` with a credential that has `media:read`." + }, + "mimeType": { + "type": "string" + }, + "fileLength": { + "type": "integer", + "minimum": 0 + }, + "url": { + "type": "string", + "description": "Path of the media download method.", + "example": "/messaging/media/0e6f1c3a-5a55-4f3c-9d4f-0b1f8f2a9c11" + } + }, + "required": ["id", "mimeType", "fileLength", "url"] + }, + "CloudOrder": { + "type": "object", + "properties": { + "catalogId": { + "type": "string", + "pattern": "^[0-9]{1,32}$", + "description": "Meta catalog ID the cart was built from." + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Text the contact sent with the order." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudOrderItem" + }, + "minItems": 1, + "maxItems": 1000 + } + }, + "required": ["catalogId", "items"], + "description": "Present on `type: \"order\"` messages when every cart line is readable. There is no order total: multiply `quantity` by `itemPrice` per item if you need one. Prices and availability can differ from your catalog; confirm them before you fulfil the order." + }, + "CloudOrderItem": { + "type": "object", + "properties": { + "productRetailerId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Your retailer ID (SKU), at most 256 UTF-8 bytes with no control characters." + }, + "quantity": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Units ordered." + }, + "itemPrice": { + "type": "number", + "minimum": 0, + "description": "Unit price as Meta reported it. Polymorfa does not convert or recompute it." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code Meta reported for the item." + } + }, + "required": ["productRetailerId", "quantity", "itemPrice", "currency"] + }, + "HistoryConversation": { + "allOf": [ + { + "$ref": "#/components/schemas/ConversationIdentity" + }, + { + "type": "object", + "properties": { + "sender": { + "allOf": [ + { + "$ref": "#/components/schemas/ConversationIdentity" + }, + { + "description": "Author of an inbound group message." + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "ConversationIdentity": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string", + "pattern": "^\\+[1-9]\\d{1,14}$" + }, + "bsuid": { + "type": "string", + "pattern": "^[A-Z]{2}\\.[A-Za-z0-9]{1,128}$" + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["id"], + "additionalProperties": false + }, + "WhatsAppMessageIds": { + "anyOf": [ + { + "type": "object", + "properties": { + "linked_devices": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "official_api": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": ["linked_devices"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "linked_devices": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "official_api": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": ["official_api"], + "additionalProperties": false + } + ], + "description": "Exact message references observed through Linked Devices, the Official API, or both. Use the Polymorfa message id for actions and replies. Unknown provider references are omitted." + }, + "ConversationRef": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.…`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + }, + "sender": { + "$ref": "#/components/schemas/IdentityRef" + } + }, + "required": ["id"] + }, + "IdentityRef": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.…`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + } + }, + "required": ["id"] + }, + "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_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", + "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"] + }, + "ListCloudCatalogProductsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/CloudCatalogProductPage" + } + }, + "required": ["success", "data"] + }, + "CloudCatalogProductPage": { + "type": "object", + "properties": { + "catalog": { + "$ref": "#/components/schemas/CloudCatalogSync" + }, + "products": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudCatalogProduct" + } + }, + "nextCursor": { + "type": "string", + "description": "Pass as `cursor` for the next page. Absent on the last page." + } + }, + "required": ["catalog", "products"] + }, + "CloudCatalogProduct": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Meta product item ID" + }, + "retailerId": { + "type": "string", + "description": "Your retailer ID (SKU). Use it in product and product list messages." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "string", + "description": "Price exactly as Meta returned it." + }, + "currency": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "reviewStatus": { + "type": "string" + }, + "visibility": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["id"] + }, + "CloudCatalogSync": { + "type": "object", + "properties": { + "catalogId": { + "type": "string" + }, + "syncedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "When the stored products were last read completely from Meta. Null before the first complete read." + }, + "stale": { + "type": "boolean", + "description": "True when there is no complete read or the last one is more than 24 hours old. Polymorfa does not refresh the copy on its own." + }, + "productCount": { + "type": "integer", + "minimum": 0 + }, + "lastAttempt": { + "type": "object", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "outcome": { + "type": "string", + "enum": [ + "in_progress", + "complete", + "provider_error", + "unavailable", + "too_large", + "not_linked" + ], + "description": "`complete` replaced the stored products. `provider_error`, `unavailable` and `too_large` left them unchanged. `not_linked` removed them because Meta no longer lists the catalog for this account." + }, + "metaErrorCode": { + "type": "integer", + "description": "Meta's error code for `provider_error`." + } + }, + "required": ["startedAt", "finishedAt", "outcome"] + } + }, + "required": [ + "catalogId", + "syncedAt", + "stale", + "productCount", + "lastAttempt" + ] + }, + "SyncCloudCatalogResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/CloudCatalogSync" + } + }, + "required": ["success", "data"] + }, + "GetCloudCatalogResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/CloudCatalogSync" + } + }, + "required": ["success", "data"] + }, + "ListCloudCatalogsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "type": "object", + "properties": { + "catalogs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudCatalogSync" + } + } + }, + "required": ["catalogs"] + } + }, + "required": ["success", "data"] + } + } + } +} diff --git a/contracts/official-api-round2/oa-17.json b/contracts/official-api-round2/oa-17.json new file mode 100644 index 00000000..921b2927 --- /dev/null +++ b/contracts/official-api-round2/oa-17.json @@ -0,0 +1,558 @@ +{ + "paths": { + "/messaging/{session}/marketing-messages/status": { + "get": { + "operationId": "getMarketingMessagesStatus", + "tags": ["Marketing Messages"], + "summary": "Get Marketing Messages onboarding status", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `sessions:read`\n\nReads WhatsApp's Marketing Messages onboarding status, terms of service state and Cloud API marketing setting for the Number's WhatsApp Business Account. The status can change between this read and a send; WhatsApp decides each send. Beta: requires team enrollment and a Number with an Official API connection.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Number ID." + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMarketingMessagesStatusResponse" + } + } + }, + "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": "The Number ID fails validation (`invalid_parameter`); it must contain 1 to 128 characters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The credential lacks `sessions:read`, or the team is not enrolled in the beta (`feature_unavailable`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The Number is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The Number has no connected Official API account, for example a Linked Devices or test Number (`unsupported_for_connection`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "WhatsApp refused the read or its answer could not be read (`upstream_failure`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "WhatsApp or a required service is unavailable (`service_unavailable`). Repeating the read is safe.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "Meta Cloud API sessions:read" + }, + "x-protocols": ["Meta Cloud API"] + } + } + }, + "components": { + "schemas": { + "MarketingOnboardingUpdatedEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "externalId": { + "type": "string", + "description": "Integrator reference from the QuickLink that created this session. Included on session lifecycle events when supplied." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": ["marketing.onboarding_updated"], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/MarketingOnboardingUpdatedPayload" + } + }, + "required": ["id", "session", "timestamp", "event", "payload"] + }, + "ContactMarketingPreferenceUpdatedEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "externalId": { + "type": "string", + "description": "Integrator reference from the QuickLink that created this session. Included on session lifecycle events when supplied." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": ["contact.marketing_preference_updated"], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/ContactMarketingPreferencePayload" + } + }, + "required": ["id", "session", "timestamp", "event", "payload"] + }, + "ContactMarketingPreferencePayload": { + "type": "object", + "properties": { + "waId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "WhatsApp user ID, preserved unchanged. It is not necessarily a phone number" + }, + "preference": { + "type": "string", + "enum": ["stop", "resume"], + "description": "`stop`: the person stopped marketing messages from your business; later marketing messages to them fail with WhatsApp error 131050 until they resume. Do not resend to them. `resume`: they accept marketing messages again" + }, + "category": { + "type": "string", + "enum": ["marketing_messages"] + }, + "phoneNumberId": { + "type": "string", + "description": "WhatsApp phone number ID of your Number" + } + }, + "required": ["waId", "preference", "category"] + }, + "MarketingOnboardingUpdatedPayload": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["terms_signed", "ad_account_linked"], + "description": "`terms_signed`: the business accepted the Marketing Messages terms for this WhatsApp Business Account. `ad_account_linked`: WhatsApp's older onboarding completion notice, which WhatsApp is retiring" + }, + "wabaId": { + "type": "string", + "description": "WhatsApp Business Account the notice is about" + }, + "ownerBusinessId": { + "type": "string", + "description": "Business portfolio that owns the account" + }, + "metaEvent": { + "type": "string", + "enum": ["MM_LITE_TERMS_SIGNED", "AD_ACCOUNT_LINKED"], + "description": "WhatsApp's `account_update` event value" + } + }, + "required": ["kind", "wabaId", "metaEvent"] + }, + "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_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"] + }, + "GetMarketingMessagesStatusResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/MarketingMessagesStatus" + } + }, + "required": ["success", "data"] + }, + "MarketingMessagesStatus": { + "type": "object", + "properties": { + "wabaId": { + "type": "string", + "description": "WhatsApp Business Account of the Number's Official API connection." + }, + "onboarding": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["onboarded", "eligible", "unknown"], + "description": "`onboarded`: WhatsApp reports the account onboarded to Marketing Messages. `eligible`: the account can onboard but has not. `unknown`: WhatsApp returned no status or a value it does not document." + }, + "metaStatus": { + "type": "string", + "nullable": true, + "description": "WhatsApp's `marketing_messages_onboarding_status` value, unchanged.", + "example": "ONBOARDED" + } + }, + "required": ["status", "metaStatus"] + }, + "terms": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["signed", "requested", "not_started", "unknown"], + "description": "Terms of service state of the business portfolio that owns the account." + }, + "metaStatus": { + "type": "string", + "nullable": true, + "description": "WhatsApp's value, unchanged: `TERM_OF_SERVICE_SIGNED`, `REQUEST_SENT` or `NOT_STARTED`.", + "example": "TERM_OF_SERVICE_SIGNED" + }, + "date": { + "type": "string", + "nullable": true, + "description": "Date WhatsApp reports for the terms state (YYYY-MM-DD).", + "example": "2025-08-13" + } + }, + "required": ["status", "metaStatus", "date"] + }, + "cloudApiMarketingDisabled": { + "type": "boolean", + "nullable": true, + "description": "True when the account refuses Marketing templates sent as ordinary messages (`marketing_disabled_on_cloud_api`). Null when WhatsApp did not report it." + }, + "nextAction": { + "type": "string", + "enum": ["none", "accept_terms", "review_in_whatsapp_manager"], + "description": "`accept_terms`: someone with full control of the business portfolio accepts the Marketing Messages terms in WhatsApp Manager (Overview, Alerts). `review_in_whatsapp_manager`: check the account in WhatsApp Manager. There is no API for either step." + }, + "guideUrl": { + "type": "string", + "format": "uri", + "description": "WhatsApp's onboarding guide for the terms step." + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "wabaId", + "onboarding", + "terms", + "cloudApiMarketingDisabled", + "nextAction", + "guideUrl", + "observedAt" + ] + } + } + } +} diff --git a/contracts/official-api-round2/oa-18.json b/contracts/official-api-round2/oa-18.json new file mode 100644 index 00000000..8be9402d --- /dev/null +++ b/contracts/official-api-round2/oa-18.json @@ -0,0 +1,937 @@ +{ + "paths": { + "/messaging/{session}/contacts/{contactId}/block": { + "post": { + "operationId": "blockContact", + "tags": ["Contacts"], + "summary": "Block a contact", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "contacts:manage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "required": true, + "name": "contactId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contact blocked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "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" + } + } + }, + "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" + } + } + } + }, + "x-mint": { + "content": "Linked Device API contacts:manage" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `contacts:manage`" + } + }, + "/messaging/{session}/contacts/{contactId}/unblock": { + "post": { + "operationId": "unblockContact", + "tags": ["Contacts"], + "summary": "Unblock a contact", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "contacts:manage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "required": true, + "name": "contactId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Contact unblocked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "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" + } + } + }, + "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" + } + } + } + }, + "x-mint": { + "content": "Linked Device API contacts:manage" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `contacts:manage`" + } + } + }, + "components": { + "schemas": { + "IdentityRef": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.\u2026`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + } + }, + "required": ["id"] + }, + "ContactUpdatePayload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "bsuid": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.\u2026`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + }, + "identityChange": { + "$ref": "#/components/schemas/ContactIdentityChange" + }, + "fullName": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "pushName": { + "type": "string" + }, + "oldPushName": { + "type": "string" + }, + "businessName": { + "type": "string" + }, + "oldBusinessName": { + "type": "string" + }, + "pictureId": { + "type": "string" + }, + "pictureRemoved": { + "type": "boolean" + } + }, + "required": ["id"] + }, + "ContactIdentityChange": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["phone_number_changed", "user_id_changed"], + "description": "`phone_number_changed`: the person changed their phone number, which also gives them a new business-scoped user ID. `user_id_changed`: their business-scoped user ID changed and WhatsApp did not share a phone number." + }, + "messageId": { + "type": "string", + "description": "WhatsApp's ID for the notification that reported the change." + }, + "previous": { + "$ref": "#/components/schemas/ContactPreviousIdentity" + } + }, + "required": ["reason", "messageId"], + "description": "Present when WhatsApp reported that the person's identifiers changed. The event's own identity fields describe the new identity." + }, + "ContactPreviousIdentity": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "bsuid": { + "type": "string" + }, + "parentBsuid": { + "type": "string" + } + }, + "description": "The identity the person had before the change. `id` is the contact key of that earlier identity." + }, + "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_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", + "whatsapp_account_restricted", + "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"] + }, + "SuccessResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": ["success"] + }, + "OrderPaymentUpdatedPayload": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["payment_status", "payment_method_selected"], + "description": "`payment_status` when WhatsApp reports a payment status; `payment_method_selected` when the buyer confirmed a one-click payment method" + }, + "reportedBy": { + "type": "string", + "enum": ["whatsapp"], + "description": "Polymorfa relays this report; it does not process or confirm funds" + }, + "providerEventId": { + "type": "string", + "description": "WhatsApp's notification or message ID" + }, + "referenceId": { + "type": "string", + "description": "Your order reference ID from the order details message", + "example": "order-1522" + }, + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.\u2026`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + } + }, + "description": "The buyer" + }, + "status": { + "type": "string", + "description": "Payment status as WhatsApp reported it", + "example": "captured" + }, + "amount": { + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": ["value", "offset"], + "description": "Reported amount; value divided by offset is the amount in currency units" + }, + "currency": { + "type": "string", + "example": "BRL" + }, + "transaction": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerTransactionId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "method": { + "type": "string" + }, + "errorCode": { + "type": "string" + } + }, + "description": "Payment provider transaction as WhatsApp reported it" + }, + "messageId": { + "type": "string", + "description": "Message that carried a payment method confirmation" + }, + "paymentMethod": { + "type": "string", + "example": "offsite_card_pay" + }, + "lastFourDigits": { + "type": "string" + }, + "credentialId": { + "type": "string", + "description": "Your credential ID from the order's payment settings" + }, + "paymentTimestamp": { + "type": "integer", + "description": "Unix time in seconds reported by WhatsApp" + } + }, + "required": [ + "kind", + "reportedBy", + "providerEventId", + "referenceId", + "conversation" + ] + }, + "CallEndedPayload": { + "type": "object", + "properties": { + "from": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.\u2026`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + } + }, + "required": ["id"] + }, + "callId": { + "type": "string" + }, + "durationSeconds": { + "type": "integer", + "description": "Total connected duration in seconds (0 if never answered).", + "example": 42 + }, + "reason": { + "type": "string", + "description": "Termination reason: user_hangup | timeout | lost_connection | rejected | capacity | pod_lost | connection_failed | upstream_failed | call_restricted (WhatsApp refused the call because the number is restricted to calling people it has already chatted with).", + "example": "user_hangup" + }, + "direction": { + "type": "string", + "enum": ["inbound", "outbound"], + "description": "Call direction.", + "example": "inbound" + }, + "hadVideo": { + "type": "boolean", + "description": "Whether the call carried video.", + "example": false + }, + "sessionConnection": { + "type": "string", + "enum": ["linked_device", "cloud_api"], + "description": "Connection of the session that carries the call: a linked device or the WhatsApp Business Cloud API.", + "example": "linked_device" + } + }, + "required": [ + "from", + "callId", + "durationSeconds", + "reason", + "direction", + "hadVideo" + ] + }, + "ConversationRef": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Opaque Polymorfa resource ID. Keep this value as a string.", + "example": "739182640518203" + }, + "phoneNumber": { + "type": "string" + }, + "bsuid": { + "type": "string" + }, + "username": { + "type": "string" + }, + "parentBsuid": { + "type": "string", + "description": "Parent business-scoped user ID (`CC.ENT.\u2026`), present only when WhatsApp reports one for this person. It identifies the person across the business portfolios enrolled in one parent account. It cannot be used to send messages or block people; use `id` or `bsuid`.", + "example": "US.ENT.11815799212886844830" + }, + "sender": { + "$ref": "#/components/schemas/IdentityRef" + } + }, + "required": ["id"] + } + } + } +} diff --git a/contracts/official-api-round2/oa-21.json b/contracts/official-api-round2/oa-21.json new file mode 100644 index 00000000..5ba8b3f4 --- /dev/null +++ b/contracts/official-api-round2/oa-21.json @@ -0,0 +1,499 @@ +{ + "paths": { + "/messaging/{session}/official-groups/{group}/picture": { + "put": { + "operationId": "setOfficialGroupPicture", + "tags": ["Official groups"], + "summary": "Set an Official group picture", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `groups:manage`\n\nReplaces the group's profile picture. Send `multipart/form-data` with one `file` part: a square JPEG of at least 192 x 192 pixels and at most 5 MB. A `group.update` event with action `settings_updated` reports whether WhatsApp applied it. Repeating the same upload is safe. Beta: requires team enrollment and a Number with an Official API connection whose WhatsApp Business Account is an Official Business Account.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "groups:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Number ID." + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$", + "description": "Conversation ID of the group.", + "example": "739182640518203" + }, + "required": true, + "name": "group", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SetOfficialGroupPictureRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetOfficialGroupPictureResponse" + } + } + }, + "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": "A parameter is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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" + } + } + }, + "402": { + "description": "Account or billing restrictions prevent the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The credential lacks the scope, the team is not enrolled in the beta (`feature_unavailable`), the number is not eligible for groups (`whatsapp_groups_ineligible`), group creation is paused (`whatsapp_group_creation_paused`), or the group is suspended (`whatsapp_group_suspended`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The Number or group is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The Number has no Official API connection (`unsupported_for_connection`), or a WhatsApp group limit was reached (`whatsapp_group_limit_reached`, `whatsapp_group_full`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "The picture is larger than 5 MB (`media_too_large`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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" + } + } + }, + "429": { + "description": "WhatsApp is limiting group requests from this number (`whatsapp_rate_limited`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "WhatsApp's response could not be read.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "A required service is unavailable. For a change, WhatsApp may have applied it: read the group before repeating it.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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" + } + } + }, + "504": { + "description": "The reply deadline elapsed after dispatch (`result_unknown`). The picture may have changed; read the group before repeating the upload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + } + } + }, + "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": "Meta Cloud API groups:manage" + }, + "x-protocols": ["Meta Cloud 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"] + }, + "SetOfficialGroupPictureResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/OfficialGroupChangeAccepted" + } + }, + "required": ["success", "data"] + }, + "OfficialGroupChangeAccepted": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "enum": [true], + "description": "WhatsApp accepted the request. Its outcome arrives as a `group.update` or `group.participant` event." + } + }, + "required": ["accepted"] + }, + "SetOfficialGroupPictureRequest": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "The picture: a square JPEG of at least 192 x 192 pixels and at most 5 MB.", + "format": "binary" + } + }, + "required": ["file"] + } + } + } +} diff --git a/contracts/official-api-round2/oa-30.json b/contracts/official-api-round2/oa-30.json new file mode 100644 index 00000000..5267b681 --- /dev/null +++ b/contracts/official-api-round2/oa-30.json @@ -0,0 +1,2384 @@ +{ + "paths": { + "/platform/webhooks": { + "get": { + "operationId": "listOrganizationWebhooks", + "tags": ["webhooks"], + "summary": "List team webhooks", + "description": "Lists only team-owned webhook endpoints. Project-owned endpoints are excluded.\n\nAPI keys require `webhooks:read`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "eventType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "enabled", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationWebhook" + } + }, + "page": { + "$ref": "#/components/schemas/CursorPageMetadata" + } + }, + "required": ["data", "page"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "operationId": "createOrganizationWebhook", + "tags": ["webhooks"], + "summary": "Create an team webhook", + "description": "Creates an team-owned webhook and returns its generated signing secret once.\n\nAPI keys require `webhooks:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhookCreationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/webhooks/{webhookId}": { + "get": { + "operationId": "getOrganizationWebhook", + "tags": ["webhooks"], + "summary": "Get an team webhook", + "description": "Returns one team-owned webhook without secret values.\n\nAPI keys require `webhooks:read`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhook" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "operationId": "updateOrganizationWebhook", + "tags": ["webhooks"], + "summary": "Update an team webhook", + "description": "Updates settings without rotating the signing secret.\n\nAPI keys require `webhooks:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhookMutationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + }, + "delete": { + "operationId": "deleteOrganizationWebhook", + "tags": ["webhooks"], + "summary": "Delete an team webhook", + "description": "Deletes one team-owned webhook.\n\nAPI keys require `webhooks:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhookDeletionReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/webhooks/{webhookId}/secret-rotations": { + "post": { + "operationId": "rotateOrganizationWebhookSecret", + "tags": ["webhooks"], + "summary": "Rotate an team webhook secret", + "description": "Rotates the signing secret and returns the new secret once.\n\nAPI keys require `webhooks:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateWebhookSecretInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhookSecretRotationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/webhooks/{webhookId}/tests": { + "post": { + "operationId": "testOrganizationWebhook", + "tags": ["webhooks"], + "summary": "Send a team webhook test", + "description": "Creates a synthetic team event and a traceable webhook delivery.\n\nAPI keys require `webhooks:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestOrganizationWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/OrganizationWebhookTestReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/projects/{projectId}/webhooks": { + "get": { + "operationId": "listProjectWebhooks", + "tags": ["webhooks"], + "summary": "List project webhooks", + "description": "Lists webhooks owned by the exact project in the route.\n\nRequires `webhooks:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "eventType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "enabled", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectWebhook" + } + }, + "page": { + "$ref": "#/components/schemas/CursorPageMetadata" + } + }, + "required": ["data", "page"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "createProjectWebhook", + "tags": ["webhooks"], + "summary": "Create a project webhook", + "description": "Creates a project-owned webhook and returns its generated signing secret once.\n\nRequires `webhooks:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhookCreationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/projects/{projectId}/webhooks/{webhookId}": { + "get": { + "operationId": "getProjectWebhook", + "tags": ["webhooks"], + "summary": "Get a project webhook", + "description": "Returns one webhook owned by the exact project without secret values.\n\nRequires `webhooks:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhook" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "operationId": "updateProjectWebhook", + "tags": ["webhooks"], + "summary": "Update a project webhook", + "description": "Updates settings without rotating the signing secret.\n\nRequires `webhooks:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhookMutationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + }, + "delete": { + "operationId": "deleteProjectWebhook", + "tags": ["webhooks"], + "summary": "Delete a project webhook", + "description": "Deletes one webhook owned by the exact project.\n\nRequires `webhooks:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhookDeletionReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/projects/{projectId}/webhooks/{webhookId}/secret-rotations": { + "post": { + "operationId": "rotateProjectWebhookSecret", + "tags": ["webhooks"], + "summary": "Rotate a project webhook secret", + "description": "Rotates the signing secret and returns the new secret once.\n\nRequires `webhooks:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateWebhookSecretInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhookSecretRotationReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/projects/{projectId}/webhooks/{webhookId}/tests": { + "post": { + "operationId": "testProjectWebhook", + "tags": ["webhooks"], + "summary": "Send a project webhook test", + "description": "Creates a synthetic project event and a traceable webhook delivery. An optional complete native envelope is accepted only with an exact HMS-enabled project session.\n\nRequires `webhooks:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "webhooks:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestProjectWebhookInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ProjectWebhookTestReceipt" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "422": { + "$ref": "#/components/responses/Unprocessable" + } + } + } + } + }, + "components": { + "schemas": { + "WebhookBatching": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "maxEvents": { + "type": "integer", + "minimum": 2, + "maximum": 100 + }, + "windowMs": { + "type": "integer", + "minimum": 100, + "maximum": 1000 + } + }, + "required": ["enabled", "maxEvents", "windowMs"] + }, + "WebhookBatchingInput": { + "type": "object", + "additionalProperties": false, + "description": "Opt-in native webhook conversation batching beta. On create, omission keeps single-event delivery. On PATCH, omission preserves the stored batching settings. Requires feature availability and enrollment. Events without replayable conversation payloads use single delivery.", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxEvents": { + "type": "integer", + "minimum": 2, + "maximum": 100, + "default": 100 + }, + "windowMs": { + "type": "integer", + "minimum": 100, + "maximum": 1000, + "default": 1000 + } + }, + "required": ["enabled"] + }, + "WebhookEventBatch": { + "type": "object", + "additionalProperties": false, + "description": "Version 1 batch of existing native event objects. A retry keeps this ID, membership, order and JSON bytes. Acknowledge the whole request with 2xx; partial acknowledgement is unsupported.", + "properties": { + "object": { + "type": "string", + "enum": ["event_batch"] + }, + "version": { + "type": "integer", + "enum": [1] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "events": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "required": ["object", "version", "id", "events"] + }, + "ProjectWebhookTestReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "operationId": { + "type": "string", + "format": "uuid" + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + }, + "eventId": { + "type": "string", + "format": "uuid" + }, + "deliveryId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["eventId", "deliveryId", "operationId", "idempotency"] + }, + "IdempotencyReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "replayed": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "key", "replayed", "createdAt", "expiresAt"] + }, + "TestProjectWebhookInput": { + "description": "Names reserved for internal use are rejected with HTTP 400, whether supplied as eventType or in the native event body, before a test event or delivery is created.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "eventType": { + "type": "string", + "default": "polymorfa.test", + "pattern": "^[a-z0-9][a-z0-9_.-]{0,127}$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "eventType": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_.-]{0,127}$" + }, + "body": { + "$ref": "#/components/schemas/EncodedEventPayload" + }, + "sessionId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["body", "sessionId"] + } + ] + }, + "EncodedEventPayload": { + "type": "object", + "additionalProperties": false, + "properties": { + "encoding": { + "type": "string", + "enum": ["base64"] + }, + "contentType": { + "type": "string", + "enum": ["application/json"] + }, + "data": { + "type": "string", + "format": "byte", + "maxLength": 87384, + "description": "Canonical base64 for the complete native webhook body; decoded bytes are limited to 65,536 bytes." + } + }, + "required": ["encoding", "contentType", "data"] + }, + "ProjectWebhookSecretRotationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhookId": { + "type": "string", + "format": "uuid" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "secret": { + "type": "string", + "nullable": true, + "writeOnly": true + }, + "secretAvailable": { + "type": "boolean" + }, + "secretMetadata": { + "$ref": "#/components/schemas/WebhookSigningSecretMetadata" + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": [ + "webhookId", + "operationId", + "secret", + "secretAvailable", + "secretMetadata", + "idempotency" + ] + }, + "WebhookSigningSecretMetadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "previousValidUntil": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": ["version", "createdAt", "previousValidUntil"] + }, + "RotateWebhookSecretInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "overlapSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 86400, + "default": 3600 + } + } + }, + "ProjectWebhookDeletionReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhookId": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [true] + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": ["webhookId", "deleted", "operationId", "idempotency"] + }, + "ProjectWebhookMutationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhook": { + "$ref": "#/components/schemas/ProjectWebhook" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": ["webhook", "operationId", "idempotency"] + }, + "ProjectWebhook": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organizationId": { + "type": "string", + "format": "uuid" + }, + "owner": { + "type": "string", + "enum": ["project"] + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "eventTypes": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": ["native", "meta"] + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicy" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatching" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderMetadata" + } + }, + "secret": { + "$ref": "#/components/schemas/WebhookSigningSecretMetadata" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "organizationId", + "owner", + "projectId", + "url", + "eventTypes", + "enabled", + "format", + "retryPolicy", + "batching", + "headers", + "secret", + "createdAt", + "updatedAt" + ] + }, + "WebhookHeaderMetadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": ["name"] + }, + "WebhookRetryPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "maximumAttempts": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "backoff": { + "type": "string", + "enum": ["constant", "linear", "exponential"] + }, + "initialDelaySeconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + } + }, + "required": ["maximumAttempts", "backoff", "initialDelaySeconds"] + }, + "UpdateProjectWebhookInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "eventTypes": { + "description": "Names reserved for internal use cannot be newly subscribed. Existing stored entries may be preserved during updates but never match deliveries.", + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": ["native", "meta"] + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicyInput" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatchingInput" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderInput" + } + } + }, + "minProperties": 1 + }, + "WebhookHeaderInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$" + }, + "value": { + "type": "string", + "maxLength": 4096, + "pattern": "^(?![\\s\\S]*[\\u0000-\\u0008\\u000A-\\u001F\\u007F])[\\s\\S]*$", + "x-polymorfa-max-utf8-bytes": 4096, + "writeOnly": true, + "description": "Encrypted write-only HTTP field value. Limited to 4096 UTF-8 bytes. Line breaks, NUL, and other control characters except horizontal tab are rejected." + } + }, + "required": ["name", "value"] + }, + "WebhookRetryPolicyInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "maximumAttempts": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "backoff": { + "type": "string", + "enum": ["constant", "linear", "exponential"] + }, + "initialDelaySeconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + } + }, + "required": ["maximumAttempts", "backoff", "initialDelaySeconds"] + }, + "ProjectWebhookCreationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhook": { + "$ref": "#/components/schemas/ProjectWebhook" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + }, + "secret": { + "type": "string", + "nullable": true, + "writeOnly": true + }, + "secretAvailable": { + "type": "boolean" + } + }, + "required": [ + "webhook", + "operationId", + "idempotency", + "secret", + "secretAvailable" + ] + }, + "CreateProjectWebhookInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "eventTypes": { + "description": "Names reserved for internal use cannot be newly subscribed. Existing stored entries may be preserved during updates but never match deliveries.", + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean", + "default": true + }, + "format": { + "type": "string", + "enum": ["native", "meta"], + "default": "native" + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicyInput" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatchingInput" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderInput" + } + } + }, + "required": ["url", "eventTypes"] + }, + "CursorPageMetadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "nextCursor": { + "type": "string", + "nullable": true + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["nextCursor", "hasMore"] + }, + "OrganizationWebhookTestReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "operationId": { + "type": "string", + "format": "uuid" + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + }, + "eventId": { + "type": "string", + "format": "uuid" + }, + "deliveryId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["eventId", "deliveryId", "operationId", "idempotency"] + }, + "TestOrganizationWebhookInput": { + "type": "object", + "additionalProperties": false, + "description": "A team webhook test accepts an optional event type. Supplied event bodies and session IDs are rejected with HTTP 400.", + "properties": { + "eventType": { + "type": "string", + "default": "polymorfa.test", + "pattern": "^[a-z0-9][a-z0-9_.-]{0,127}$" + } + } + }, + "OrganizationWebhookSecretRotationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhookId": { + "type": "string", + "format": "uuid" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "secret": { + "type": "string", + "nullable": true, + "writeOnly": true + }, + "secretAvailable": { + "type": "boolean" + }, + "secretMetadata": { + "$ref": "#/components/schemas/WebhookSigningSecretMetadata" + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": [ + "webhookId", + "operationId", + "secret", + "secretAvailable", + "secretMetadata", + "idempotency" + ] + }, + "OrganizationWebhookDeletionReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhookId": { + "type": "string", + "format": "uuid" + }, + "deleted": { + "type": "boolean", + "enum": [true] + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": ["webhookId", "deleted", "operationId", "idempotency"] + }, + "OrganizationWebhookMutationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhook": { + "$ref": "#/components/schemas/OrganizationWebhook" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + } + }, + "required": ["webhook", "operationId", "idempotency"] + }, + "OrganizationWebhook": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organizationId": { + "type": "string", + "format": "uuid" + }, + "owner": { + "type": "string", + "enum": ["organization"] + }, + "projectId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "eventTypes": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": ["native", "meta"] + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicy" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatching" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderMetadata" + } + }, + "secret": { + "$ref": "#/components/schemas/WebhookSigningSecretMetadata" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "organizationId", + "owner", + "projectId", + "url", + "eventTypes", + "enabled", + "format", + "retryPolicy", + "batching", + "headers", + "secret", + "createdAt", + "updatedAt" + ] + }, + "UpdateOrganizationWebhookInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "eventTypes": { + "description": "Names reserved for internal use cannot be newly subscribed. Existing stored entries may be preserved during updates but never match deliveries.", + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": ["native", "meta"] + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicyInput" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatchingInput" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderInput" + } + } + }, + "minProperties": 1 + }, + "OrganizationWebhookCreationReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhook": { + "$ref": "#/components/schemas/OrganizationWebhook" + }, + "operationId": { + "type": "string", + "nullable": true, + "enum": [null] + }, + "idempotency": { + "$ref": "#/components/schemas/IdempotencyReceipt" + }, + "secret": { + "type": "string", + "nullable": true, + "writeOnly": true + }, + "secretAvailable": { + "type": "boolean" + } + }, + "required": [ + "webhook", + "operationId", + "idempotency", + "secret", + "secretAvailable" + ] + }, + "CreateOrganizationWebhookInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "eventTypes": { + "description": "Names reserved for internal use cannot be newly subscribed. Existing stored entries may be preserved during updates but never match deliveries.", + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "enabled": { + "type": "boolean", + "default": true + }, + "format": { + "type": "string", + "enum": ["native", "meta"], + "default": "native" + }, + "retryPolicy": { + "$ref": "#/components/schemas/WebhookRetryPolicyInput" + }, + "batching": { + "$ref": "#/components/schemas/WebhookBatchingInput" + }, + "headers": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/WebhookHeaderInput" + } + } + }, + "required": ["url", "eventTypes"] + } + } + } +} diff --git a/docs/official-api-round2.md b/docs/official-api-round2.md new file mode 100644 index 00000000..dd0351a8 --- /dev/null +++ b/docs/official-api-round2.md @@ -0,0 +1,43 @@ +# Official API round-2 betas + +These client additions prepare the six merged backend contracts in +`contracts/official-api-round2.json`. They do not establish a published package, +deployment or enabled audience. Use the matching accepted SDK prerelease after +publication. API contract dates are independent of package timestamps. + +| Item | TypeScript surface | Scope | +| ----- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OA-03 | `quickLinks.retrieve`, `CloudReadinessFailureCode`, named send errors | Readiness is checked by the backend. `meta_readiness_unknown` stays pending until cancellation or expiry; it never proves readiness. | +| OA-16 | `catalogs.list/retrieve/sync/listProducts`, incoming `order` in webhook/history types | Native Number routes, `business:read`, enrolled beta. Reads use the snapshot; sync explicitly reads Meta. Prices remain provider values. An invalid order cart is omitted as a whole. | +| OA-17 | `cloudMarketing.getStatus`, refreshed Graph `cloudMarketing.status`, two lifecycle events | Native beta requires `sessions:read`. Unknown values never grant eligibility. Terms are accepted in WhatsApp Manager. The deprecated MM Lite Graph field is removed from the current response type. | +| OA-18 | `contacts.block/unblock`, `parentBsuid`, `contact.update.identityChange` | Use public contact IDs for native blocks. Parent BSUIDs are metadata, never recipients or block targets. Previous identities stay separate. Reuse the existing native methods and caller idempotency keys; inspect uncertain outcomes before starting another operation. | +| OA-21 | `officialGroups.setPicture`, `groups.setPicture`, `chats` history | Official-only picture is a multipart JPEG, square, at least 192 x 192 pixels, at most 5 MB. Server validation checks bytes/dimensions. Hybrid operations use `groups` through Linked Devices only. `hybrid_group_linked_devices_unavailable` means not executed; never retry through Official. History remains opt-in and regional. | +| OA-30 | Platform `client.webhooks.create/update`, `constructWebhookDelivery`, `webhooks.verifyDelivery` | Native-format enrolled beta, opt-in/default off. The older `messaging.webhooks` resource does not accept batching. | + +`feature_unavailable` is a normal typed `PolymorfaError` with its HTTP status, +request ID and code retained. Do not fall back to another API or bypass access +checks. Transport errors and unknown provider results must remain visible. + +Catalog IDs are strings of 1-32 digits. Product pages accept `limit` 1-100 +(default 50) and the API's opaque `nextCursor`. Sync may run for 50 seconds; +its SDK timeout defaults to 60 seconds. It is sent once, even with retry overrides. +A new attempt within 120 seconds returns `catalog_sync_cooldown` with Retry-After. +A failed attempt preserves the previous snapshot except definite `not_linked`. + +Batch settings are `{enabled, maxEvents?, windowMs?}`. Limits are 2-100 events +and 100-1000 ms; defaults are 100 and 1000. Omission on create keeps single +delivery. Omission on update preserves stored settings. Disabling remains +available after beta withdrawal. The server verifies saved format and access. + +Before enabling batching, replace a single-only receiver's +`constructWebhookEvent` with `constructWebhookDelivery`. The result is either +the existing event or `{object:"event_batch",version:1,id,events}`. A window may +contain one event. The original parser still accepts only a single event. +Verify the signature over the whole raw request. Deduplicate individual event +IDs and batch IDs in durable storage. Acknowledge only the whole request; +partial acknowledgement is unsupported. Batch retries retain bytes, membership, +order and IDs. Later admitted work may wait behind earlier endpoint deliveries. + +The compile-checked example is `examples/official-api-round2/index.ts`. +Private Console routes in backend #612, #615 and #616 have no SDK surface. +Go, Python, PHP, Rust and C# additions are coordinated separately with SDK #348. diff --git a/examples/official-api-round2/index.ts b/examples/official-api-round2/index.ts new file mode 100644 index 00000000..292c1ddc --- /dev/null +++ b/examples/official-api-round2/index.ts @@ -0,0 +1,89 @@ +import { + Client, + MessagingClient, + PolymorfaError, + constructWebhookDelivery, + isWebhookEventBatch, + isEvent, + type WebhookBody, +} from "@polymorfa/sdk"; + +/** Prepared for enrolled betas. Supply a regional Messaging base URL and a + * project token issued for that region; this example never mints credentials. + */ +export async function inspectNumber( + messaging: MessagingClient, + number: string, +) { + try { + const catalogs = await messaging.catalogs.list(number); + const marketing = await messaging.cloudMarketing.getStatus(number); + const history = await messaging.chats.list(number, { kind: "group" }); + return { + catalogs: catalogs.data.data, + marketing: marketing.data.data, + history: history.data.data, + }; + } catch (error) { + if (error instanceof PolymorfaError && error.code === "feature_unavailable") + return { unavailable: true }; + throw error; + } +} + +export async function refreshCatalog( + messaging: MessagingClient, + number: string, + catalog: string, +) { + await messaging.catalogs.sync(number, catalog); + return messaging.catalogs.listProducts(number, catalog, { limit: 50 }); +} + +/** Call only after the receiver handles single deliveries and batches. */ +export function enableBatching( + client: Client, + webhookId: string, + idempotencyKey: string, +) { + return client.webhooks.update( + webhookId, + { + format: "native", + batching: { enabled: true, maxEvents: 100, windowMs: 1000 }, + }, + { idempotencyKey }, + ); +} + +/** Verify once over the whole raw request, then deduplicate member IDs in + * durable receiver storage before effects. Return 2xx only after every member + * is durably handled; retries preserve the batch ID and individual event IDs. + */ +export async function receive( + raw: WebhookBody, + signature: string, + secret: string, + once: (eventId: string, effect: () => Promise) => Promise, +) { + const delivery = await constructWebhookDelivery(raw, signature, secret); + const events = isWebhookEventBatch(delivery) ? delivery.events : [delivery]; + for (const event of events) { + await once(event.id, async () => { + if ( + isEvent(event, "message.received") && + event.payload.order !== undefined + ) { + // Read the validated cart; it carries no computed total or payment status. + console.log(event.payload.order.items.length); + } + if ( + isEvent(event, "contact.update") && + event.payload.identityChange !== undefined + ) { + // Keep previous and new identities separate. parentBsuid is metadata only. + console.log(event.payload.identityChange.reason); + } + }); + } +} diff --git a/packages/typescript/src/errors.ts b/packages/typescript/src/errors.ts index 3db7aaab..d3f1c644 100644 --- a/packages/typescript/src/errors.ts +++ b/packages/typescript/src/errors.ts @@ -67,6 +67,14 @@ export const POLYMORFA_ERROR_CODES = [ "idempotency_in_progress", "idempotency_outcome_unknown", "feature_unavailable", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "marketing_disabled_on_cloud_api", + "parent_bsuid_not_supported", + "hybrid_group_linked_devices_unavailable", + "catalog_cache_full", + "catalog_sync_cooldown", "stream_connection_limit_reached", "stream_cursor_expired", "stream_cursor_invalid", diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 03cad965..e85b90dc 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -60,10 +60,18 @@ export { type PolymorfaRateLimitReason, } from "./errors.js"; export { MessagingClient } from "./messaging/client.js"; +export { CatalogsResource } from "./messaging/catalogs.js"; +export type { + CatalogSync, + CatalogProduct, + CatalogProductPage, + ListCatalogProductsParams, +} from "./messaging/catalogs.js"; export { CloudCatalogsResource } from "./messaging/cloud-catalogs.js"; export { CloudMarketingResource } from "./messaging/cloud-marketing.js"; export type { CloudMarketingStatus, + MarketingMessagesStatus, GetCloudMarketingStatusParams, } from "./messaging/cloud-marketing.js"; export { FlowEncryptionResource } from "./messaging/flow-encryption.js"; @@ -1108,6 +1116,13 @@ export { PHONE_OFFLINE_START_DAY, WebhookSignatureError, constructWebhookEvent, + constructWebhookDelivery, + isWebhookEventBatch, + type WebhookDelivery, + type WebhookEventBatch, + type ContactIdentityChange, + type MarketingOnboardingUpdatedPayload, + type ContactMarketingPreferencePayload, isEvent, webhooks, verifyWebhookSignature, @@ -1338,3 +1353,6 @@ export type { export * from "./platform/audience-segments.js"; export * from "./messaging/campaign-drip.js"; + +export type { IncomingOrderCart } from "./messaging/order-cart.js"; +export type { CloudReadinessFailureCode } from "./messaging/cloud-readiness.js"; diff --git a/packages/typescript/src/messaging/catalogs.ts b/packages/typescript/src/messaging/catalogs.ts new file mode 100644 index 00000000..f951be07 --- /dev/null +++ b/packages/typescript/src/messaging/catalogs.ts @@ -0,0 +1,146 @@ +import type { MessagingCredential } from "../credentials.js"; +import { + PolymorfaConfigurationError, + PolymorfaValidationError, +} from "../errors.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +import type { SuccessEnvelope } from "./types.js"; + +export interface CatalogSync { + readonly catalogId: string; + readonly syncedAt: string | null; + readonly stale: boolean; + readonly productCount: number; + readonly lastAttempt: { + readonly startedAt: string; + readonly finishedAt: string | null; + readonly outcome: + | "in_progress" + | "complete" + | "provider_error" + | "unavailable" + | "too_large" + | "not_linked"; + readonly metaErrorCode?: number; + }; +} +export interface CatalogProduct { + readonly id: string; + readonly retailerId?: string; + readonly name?: string; + readonly description?: string; + /** Preserved exactly as Meta returned it. */ + readonly price?: string; + readonly currency?: string; + readonly availability?: string; + readonly reviewStatus?: string; + readonly visibility?: string; + readonly imageUrl?: string; + readonly url?: string; +} +export interface CatalogProductPage { + readonly catalog: CatalogSync; + readonly products: readonly CatalogProduct[]; + readonly nextCursor?: string; +} +export interface ListCatalogProductsParams { + /** 1-100, default 50. */ + readonly limit?: number; + readonly cursor?: string; +} + +/** Beta, business:read. Reads use the saved snapshot; only sync contacts Meta. + * Requires enrollment and an active Official connection, including on Hybrid. + * Unavailable betas return feature_unavailable; no client-side access grant. + */ +export class CatalogsResource { + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + + list( + session: string, + options: RequestOptions = {}, + ): Promise< + ApiResponse> + > { + return this.transport.request({ + method: "GET", + path: this.path(session), + ...options, + }); + } + retrieve( + session: string, + catalogId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: "GET", + path: this.path(session, catalogId), + ...options, + }); + } + /** Explicit refresh, at most once per 120 seconds. A failed attempt preserves + * the snapshot except not_linked. Not automatically retried, even on 429/503. + * A full sync can take 50 seconds; this method allows 60 seconds by default. + */ + sync( + session: string, + catalogId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: "POST", + path: `${this.path(session, catalogId)}/sync`, + timeoutMs: 60_000, + ...options, + maxNetworkRetries: 0, + }); + } + listProducts( + session: string, + catalogId: string, + params: ListCatalogProductsParams = {}, + options: RequestOptions = {}, + ): Promise>> { + if ( + params.limit !== undefined && + (!Number.isInteger(params.limit) || + params.limit < 1 || + params.limit > 100) + ) + throw new PolymorfaValidationError( + "limit must be an integer from 1 to 100.", + ); + if ( + params.cursor !== undefined && + !/^[A-Za-z0-9_-]{1,256}$/.test(params.cursor) + ) + throw new PolymorfaValidationError( + "cursor must be the nextCursor returned by the API.", + ); + return this.transport.request({ + method: "GET", + path: `${this.path(session, catalogId)}/products`, + query: { ...params }, + ...options, + }); + } + private path(session: string, catalogId?: string): string { + if (this.credentialType === "clientToken") + throw new PolymorfaConfigurationError( + "Catalog sync requires an organization API key or project token.", + "credential", + ); + if (!session.trim()) + throw new PolymorfaValidationError("Provide a Number ID."); + if (catalogId !== undefined && !/^[0-9]{1,32}$/.test(catalogId)) + throw new PolymorfaValidationError( + "catalogId must contain 1 to 32 digits.", + ); + return `/messaging/${encodeURIComponent(session)}/catalogs${catalogId === undefined ? "" : `/${encodeURIComponent(catalogId)}`}`; + } +} diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index 8327eb3e..726132a8 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -30,6 +30,7 @@ import { SessionsResource } from "./sessions.js"; import { TemplatesResource } from "./templates.js"; import { CloudTemplatesResource } from "./cloud-templates.js"; import { OfficialGroupsResource } from "./official-groups.js"; +import { CatalogsResource } from "./catalogs.js"; import { CloudCatalogsResource } from "./cloud-catalogs.js"; import { CloudMarketingResource } from "./cloud-marketing.js"; import { FlowEncryptionResource } from "./flow-encryption.js"; @@ -65,6 +66,7 @@ export class MessagingClient { readonly cloudTemplates: CloudTemplatesResource; /** Beta: WhatsApp groups on an Official API connection (Groups API). */ readonly officialGroups: OfficialGroupsResource; + readonly catalogs: CatalogsResource; readonly cloudCatalogs: CloudCatalogsResource; readonly cloudMarketing: CloudMarketingResource; readonly flowEncryption: FlowEncryptionResource; @@ -121,6 +123,7 @@ export class MessagingClient { transport, credential.type, ); + this.catalogs = new CatalogsResource(transport, credential.type); this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); this.cloudMarketing = new CloudMarketingResource( transport, diff --git a/packages/typescript/src/messaging/cloud-marketing.ts b/packages/typescript/src/messaging/cloud-marketing.ts index 33ffc43d..8fd3f895 100644 --- a/packages/typescript/src/messaging/cloud-marketing.ts +++ b/packages/typescript/src/messaging/cloud-marketing.ts @@ -1,13 +1,22 @@ import type { MessagingCredential } from "../credentials.js"; -import { PolymorfaConfigurationError } from "../errors.js"; +import { + PolymorfaConfigurationError, + PolymorfaValidationError, +} from "../errors.js"; import { HttpTransport } from "../transport/http.js"; import type { ApiResponse, RequestOptions } from "../transport/types.js"; /** Meta WABA observations. Neither string is an eligibility or terms grant. */ export interface CloudMarketingStatus { readonly id: string; - readonly marketing_messages_lite_api_status?: string; readonly marketing_messages_onboarding_status?: string; + readonly owner_business_info?: { + readonly marketing_messages_onboarding_status?: { + readonly status?: string; + readonly time?: string; + }; + }; + readonly disable_marketing_messages_on_cloud_api?: boolean; } export interface GetCloudMarketingStatusParams { @@ -15,12 +24,57 @@ export interface GetCloudMarketingStatusParams { readonly version: string; } +export interface MarketingMessagesStatus { + readonly wabaId: string; + readonly onboarding: { + readonly status: "onboarded" | "eligible" | "unknown"; + readonly metaStatus: string | null; + }; + readonly terms: { + readonly status: "signed" | "requested" | "not_started" | "unknown"; + readonly metaStatus: string | null; + readonly date: string | null; + }; + readonly cloudApiMarketingDisabled: boolean | null; + readonly nextAction: "none" | "accept_terms" | "review_in_whatsapp_manager"; + readonly guideUrl: string; + readonly observedAt: string; +} + export class CloudMarketingResource { constructor( private readonly transport: HttpTransport, private readonly credentialType: MessagingCredential["type"], ) {} + /** Beta, sessions:read. Terms acceptance happens in WhatsApp Manager. + * feature_unavailable means the team has no access; unknown never means eligible. + */ + getStatus( + session: string, + options: RequestOptions = {}, + ): Promise< + ApiResponse<{ + readonly success: true; + readonly data: MarketingMessagesStatus; + }> + > { + if (this.credentialType === "clientToken") + throw new PolymorfaConfigurationError( + "Marketing Messages status requires an organization API key or project token.", + "credential", + ); + if (!session.trim() || session.length > 128) + throw new PolymorfaValidationError( + "Provide a Number ID of 1 to 128 characters.", + ); + return this.transport.request({ + method: "GET", + path: `/messaging/${encodeURIComponent(session)}/marketing-messages/status`, + ...options, + }); + } + /** Read raw WABA status fields through a project-owned Official API Number. */ status( wabaId: string, diff --git a/packages/typescript/src/messaging/cloud-readiness.ts b/packages/typescript/src/messaging/cloud-readiness.ts new file mode 100644 index 00000000..3e36f5ba --- /dev/null +++ b/packages/typescript/src/messaging/cloud-readiness.ts @@ -0,0 +1,10 @@ +/** Readiness is checked by the backend before connection activation. Unknown + * keeps the link pending until cancellation or expiry; clients must not treat it as ready. + */ +export type CloudReadinessFailureCode = + | "meta_credentials_invalid" + | "meta_phone_not_registered" + | "meta_webhook_not_subscribed" + | "meta_payment_method_missing" + | "meta_quality_flagged" + | "meta_readiness_unknown"; diff --git a/packages/typescript/src/messaging/history.ts b/packages/typescript/src/messaging/history.ts index 80b6d939..b1e01250 100644 --- a/packages/typescript/src/messaging/history.ts +++ b/packages/typescript/src/messaging/history.ts @@ -1,3 +1,4 @@ +import type { IncomingOrderCart } from "./order-cart.js"; import type { ConversationIdentity, WhatsAppMessageIds } from "./types.js"; /** Hosted message history is an enrolled beta and requires HMS on the Number. */ @@ -41,6 +42,7 @@ export interface HistoryMediaRetrieval { } export interface HistoryMessage extends HistoryMessageSummary { + readonly order?: IncomingOrderCart; readonly conversation: ConversationIdentity & { readonly sender?: ConversationIdentity; }; diff --git a/packages/typescript/src/messaging/official-groups.ts b/packages/typescript/src/messaging/official-groups.ts index 4e54e3c3..274254d5 100644 --- a/packages/typescript/src/messaging/official-groups.ts +++ b/packages/typescript/src/messaging/official-groups.ts @@ -1,5 +1,8 @@ import type { MessagingCredential } from "../credentials.js"; -import { PolymorfaConfigurationError } from "../errors.js"; +import { + PolymorfaConfigurationError, + PolymorfaValidationError, +} from "../errors.js"; import { HttpTransport } from "../transport/http.js"; import type { ApiResponse, RequestOptions } from "../transport/types.js"; import type { ConversationIdentity, SuccessEnvelope } from "./types.js"; @@ -293,6 +296,37 @@ export class OfficialGroupsResource { ); } + /** Beta. Official-only; Hybrid Numbers must use groups.setPicture on Linked Devices. + * Upload a square JPEG, at least 192 x 192 pixels, at most 5 MB. The server + * validates image bytes and dimensions. An event confirms the applied change. + */ + setPicture( + session: string, + group: string, + file: Blob, + options: RequestOptions = {}, + ): Promise>> { + this.requireServerCredential(); + if ( + !(file instanceof Blob) || + file.type !== "image/jpeg" || + file.size === 0 || + file.size > 5 * 1024 * 1024 + ) + throw new PolymorfaValidationError( + "Provide a nonempty JPEG of at most 5 MB, square and at least 192 x 192 pixels.", + ); + const body = new FormData(); + body.set("file", file, "picture.jpg"); + return this.transport.request({ + method: "PUT", + path: `${groupPath(session, group)}/picture`, + body, + ...options, + maxNetworkRetries: 0, + }); + } + /** Pins a group message for 1 to 30 days, or unpins it. At most three stay pinned. */ pin( session: string, diff --git a/packages/typescript/src/messaging/order-cart.ts b/packages/typescript/src/messaging/order-cart.ts new file mode 100644 index 00000000..47fe9bfb --- /dev/null +++ b/packages/typescript/src/messaging/order-cart.ts @@ -0,0 +1,13 @@ +/** Validated incoming Official API cart. No total or payment status is inferred. + * A malformed line causes the backend to omit the entire cart. + */ +export interface IncomingOrderCart { + readonly catalogId: string; + readonly text?: string; + readonly items: readonly { + readonly productRetailerId: string; + readonly quantity: number; + readonly itemPrice: number; + readonly currency: string; + }[]; +} diff --git a/packages/typescript/src/platform/developer-resources.ts b/packages/typescript/src/platform/developer-resources.ts index 17a1dc6c..e7556ff0 100644 --- a/packages/typescript/src/platform/developer-resources.ts +++ b/packages/typescript/src/platform/developer-resources.ts @@ -1,3 +1,4 @@ +import { validateWebhookBatching } from "./webhook-batching.js"; import { PolymorfaConfigurationError, PolymorfaServerError, @@ -390,6 +391,7 @@ export class WebhooksResource extends ResourceBase { input: CreateWebhookFor, options: RequestOptions = {}, ): Promise>> { + validateWebhookBatching(input); return this.mutate("POST", this.path("/webhooks"), input, options); } retrieve( @@ -406,6 +408,7 @@ export class WebhooksResource extends ResourceBase { input: UpdateWebhookFor, options: RequestOptions = {}, ): Promise>> { + validateWebhookBatching(input); return this.mutate( "PATCH", this.path(`/webhooks/${encodeURIComponent(webhookId)}`), diff --git a/packages/typescript/src/platform/developer-types.ts b/packages/typescript/src/platform/developer-types.ts index 0a99309a..88051397 100644 --- a/packages/typescript/src/platform/developer-types.ts +++ b/packages/typescript/src/platform/developer-types.ts @@ -113,7 +113,23 @@ export interface WebhookSigningSecretMetadata { readonly createdAt: string; readonly previousValidUntil: string | null; } +/** Beta, native-format endpoints only. Omission on create keeps single delivery; + * omission on update preserves saved settings. Enabling requires enrollment. + */ +export interface WebhookBatchingInput { + readonly enabled: boolean; + /** 2-100, default 100. A window may deliver a single member. */ + readonly maxEvents?: number; + /** 100-1000 ms, default 1000. */ + readonly windowMs?: number; +} +export interface WebhookBatching { + readonly enabled: boolean; + readonly maxEvents: number; + readonly windowMs: number; +} interface WebhookBase { + readonly batching: WebhookBatching; readonly id: string; readonly organizationId: string; readonly url: string; @@ -142,6 +158,7 @@ export interface ProjectWebhook extends WebhookBase { readonly projectId: string; } interface CreateWebhookInputBase { + readonly batching?: WebhookBatchingInput; readonly url: string; readonly eventTypes: readonly string[]; readonly enabled?: boolean; @@ -159,6 +176,7 @@ interface CreateWebhookInputBase { export type CreateOrganizationWebhookInput = CreateWebhookInputBase; export type CreateProjectWebhookInput = CreateWebhookInputBase; interface UpdateWebhookInputBase { + readonly batching?: WebhookBatchingInput; readonly url?: string; readonly eventTypes?: readonly string[]; readonly enabled?: boolean; diff --git a/packages/typescript/src/platform/webhook-batching.ts b/packages/typescript/src/platform/webhook-batching.ts new file mode 100644 index 00000000..4001a108 --- /dev/null +++ b/packages/typescript/src/platform/webhook-batching.ts @@ -0,0 +1,41 @@ +import { PolymorfaValidationError } from "../errors.js"; +import type { WebhookBatchingInput } from "./developer-types.js"; + +/** Validate only supplied settings; never infer a saved endpoint's format. */ +export function validateWebhookBatching(input: { + readonly format?: "native" | "meta"; + readonly batching?: WebhookBatchingInput; +}): void { + if (input.batching === undefined) return; + const b = input.batching; + if ( + typeof b !== "object" || + b === null || + Array.isArray(b) || + typeof b.enabled !== "boolean" || + Object.keys(b).some( + (key) => !["enabled", "maxEvents", "windowMs"].includes(key), + ) + ) + throw new PolymorfaValidationError( + "batching requires enabled and accepts only maxEvents and windowMs.", + ); + if ( + b.maxEvents !== undefined && + (!Number.isInteger(b.maxEvents) || b.maxEvents < 2 || b.maxEvents > 100) + ) + throw new PolymorfaValidationError( + "batching.maxEvents must be an integer from 2 to 100.", + ); + if ( + b.windowMs !== undefined && + (!Number.isInteger(b.windowMs) || b.windowMs < 100 || b.windowMs > 1000) + ) + throw new PolymorfaValidationError( + "batching.windowMs must be an integer from 100 to 1000.", + ); + if (b.enabled && input.format === "meta") + throw new PolymorfaValidationError( + "Webhook batching supports native format only.", + ); +} diff --git a/packages/typescript/src/transport/body.ts b/packages/typescript/src/transport/body.ts index b74d997d..98301899 100644 --- a/packages/typescript/src/transport/body.ts +++ b/packages/typescript/src/transport/body.ts @@ -32,6 +32,9 @@ export function encodeRequestBody(body: unknown): { if (typeof body === "string" || body instanceof ArrayBuffer) { return { body }; } + if (body instanceof FormData) { + return { body }; + } if (body instanceof Uint8Array) { return { body: Uint8Array.from(body).buffer }; } diff --git a/packages/typescript/src/webhooks/delivery.ts b/packages/typescript/src/webhooks/delivery.ts new file mode 100644 index 00000000..f005be0c --- /dev/null +++ b/packages/typescript/src/webhooks/delivery.ts @@ -0,0 +1,73 @@ +import { PolymorfaValidationError } from "../errors.js"; +import type { WebhookEvent } from "./events.js"; +import { + decodeVerifiedWebhookBody, + parseEventEnvelope, + verifyWebhookSignature, + WebhookSignatureError, + type WebhookBody, +} from "./verify.js"; + +/** Beta. Acknowledge the entire request; partial acknowledgements are unsupported. + * Retry keeps ID, order and membership. Deduplicate each member ID too. + */ +export interface WebhookEventBatch { + readonly object: "event_batch"; + readonly version: 1; + readonly id: string; + readonly events: readonly WebhookEvent[]; +} +export type WebhookDelivery = WebhookEvent | WebhookEventBatch; +export function isWebhookEventBatch( + delivery: WebhookDelivery, +): delivery is WebhookEventBatch { + return "object" in delivery && delivery.object === "event_batch"; +} +/** Verify the signature over the original whole body before decoding any member. + * Known and future event names retain the existing envelope validation contract. + */ +export async function constructWebhookDelivery( + rawBody: WebhookBody, + signatureHeader: string, + secret: string, +): Promise { + if (!(await verifyWebhookSignature(rawBody, signatureHeader, secret))) + throw new WebhookSignatureError(); + return parseVerifiedWebhookDelivery(rawBody); +} +export function parseVerifiedWebhookDelivery( + rawBody: WebhookBody, +): WebhookDelivery { + const parsed = decodeVerifiedWebhookBody(rawBody); + if ( + typeof parsed !== "object" || + parsed === null || + !("object" in parsed) || + parsed.object !== "event_batch" + ) + return parseEventEnvelope(parsed); + const batch = parsed as Record; + if ( + Object.keys(batch).some( + (key) => !["object", "version", "id", "events"].includes(key), + ) || + batch.version !== 1 || + typeof batch.id !== "string" || + !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + batch.id, + ) || + !Array.isArray(batch.events) || + batch.events.length < 1 || + batch.events.length > 100 + ) + throw new PolymorfaValidationError( + "Webhook body is not a valid version 1 event batch.", + { code: "invalid_webhook_batch" }, + ); + return { + object: "event_batch", + version: 1, + id: batch.id, + events: batch.events.map(parseEventEnvelope), + }; +} diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index 52c96b37..2128689e 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -1,3 +1,4 @@ +import type { IncomingOrderCart } from "../messaging/order-cart.js"; import type { CallPermissionSource, CallPermissionStatus, @@ -63,6 +64,8 @@ export const KNOWN_WEBHOOK_EVENT_TYPES = [ "contact.opted_out", "contact.sync", "contact.update", + "contact.marketing_preference_updated", + "marketing.onboarding_updated", "customer.archived", "customer.archiving", "customer.created", @@ -111,6 +114,8 @@ export const KNOWN_WEBHOOK_EVENT_TYPES = [ export type KnownWebhookEventType = (typeof KNOWN_WEBHOOK_EVENT_TYPES)[number]; export interface IdentityReference { + /** Metadata only; never a send or block target. */ + readonly parentBsuid?: string; readonly id: string; readonly phoneNumber?: string; readonly bsuid?: string; @@ -161,6 +166,7 @@ export type LinkedDeviceMessageType = | "unknown"; export interface LinkedDeviceMessagePayload { + readonly order?: IncomingOrderCart; readonly id: string; readonly whatsapp_ids: WhatsAppMessageIds; /** @deprecated Temporary singular reference for older consumers; use `whatsapp_ids`. */ @@ -207,6 +213,7 @@ export interface CloudMessageReferral { } export interface CloudMessagePayload { + readonly order?: IncomingOrderCart; readonly id: string; readonly whatsapp_ids: WhatsAppMessageIds; /** @deprecated Temporary singular reference for older consumers; use `whatsapp_ids`. */ @@ -441,7 +448,33 @@ export interface ContactOptPayload { readonly projectId?: string; } +export interface ContactIdentityChange { + readonly reason: "phone_number_changed" | "user_id_changed"; + readonly messageId: string; + /** Previous identity stays separate; do not merge contact records automatically. */ + readonly previous?: { + readonly id?: string; + readonly bsuid?: string; + readonly parentBsuid?: string; + }; +} +export interface MarketingOnboardingUpdatedPayload { + readonly kind: "terms_signed" | "ad_account_linked"; + readonly wabaId: string; + readonly ownerBusinessId?: string; + readonly metaEvent: "MM_LITE_TERMS_SIGNED" | "AD_ACCOUNT_LINKED"; +} +export interface ContactMarketingPreferencePayload { + /** Opaque WhatsApp user ID, not necessarily a phone number. */ + readonly waId: string; + readonly preference: "stop" | "resume"; + readonly category: "marketing_messages"; + readonly phoneNumberId?: string; +} + export interface ContactUpdatePayload { + readonly parentBsuid?: string; + readonly identityChange?: ContactIdentityChange; readonly id: string; readonly phoneNumber?: string; readonly bsuid?: string; @@ -904,6 +937,8 @@ interface OrderPaymentUpdateBase { readonly conversation: { readonly id?: string; readonly phoneNumber?: string; + readonly bsuid?: string; + readonly parentBsuid?: string; }; } @@ -1197,6 +1232,8 @@ export interface WebhookPayloadMap { readonly "contact.opted_out": ContactOptPayload; readonly "contact.sync": ContactsSyncPayload; readonly "contact.update": ContactUpdatePayload; + readonly "marketing.onboarding_updated": MarketingOnboardingUpdatedPayload; + readonly "contact.marketing_preference_updated": ContactMarketingPreferencePayload; readonly "customer.archived": CustomerArchivedPayload; readonly "customer.archiving": CustomerArchivingPayload; readonly "customer.created": CustomerCreatedPayload; diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index de61a60a..d7afd5fc 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -52,6 +52,9 @@ export { type CommandResultPayload, type ContactOptPayload, type ContactUpdatePayload, + type ContactIdentityChange, + type MarketingOnboardingUpdatedPayload, + type ContactMarketingPreferencePayload, type ContactsSyncPayload, type ConversationReference, type CustomerArchivedPayload, @@ -140,3 +143,10 @@ export { type WebhookFixture, type WebhookUtilities, } from "./utilities.js"; + +export { + constructWebhookDelivery, + isWebhookEventBatch, + type WebhookEventBatch, + type WebhookDelivery, +} from "./delivery.js"; diff --git a/packages/typescript/src/webhooks/utilities.ts b/packages/typescript/src/webhooks/utilities.ts index 1eba8b27..4feea115 100644 --- a/packages/typescript/src/webhooks/utilities.ts +++ b/packages/typescript/src/webhooks/utilities.ts @@ -1,3 +1,4 @@ +import { constructWebhookDelivery, type WebhookDelivery } from "./delivery.js"; import type { WebhookEvent } from "./events.js"; import { bytesHex, @@ -25,7 +26,7 @@ export interface VerifyLocalWebhookInput extends VerifyWebhookInput { readonly nowUnixSeconds?: number; } export interface CreateWebhookFixtureInput { - readonly event: WebhookEvent; + readonly event: WebhookDelivery; readonly secret: string; } export interface WebhookFixture { @@ -38,6 +39,7 @@ export interface WebhookFixture { }>; } export interface WebhookUtilities { + verifyDelivery(input: VerifyWebhookInput): Promise; verify(input: VerifyWebhookInput): Promise; verifySignature(input: VerifyWebhookSignatureInput): Promise; verifyLocal(input: VerifyLocalWebhookInput): Promise; @@ -45,6 +47,8 @@ export interface WebhookUtilities { } export const webhooks: WebhookUtilities = Object.freeze({ + verifyDelivery: (input: VerifyWebhookInput) => + constructWebhookDelivery(input.body, input.signature, input.secret), verify: (input: VerifyWebhookInput) => constructWebhookEvent(input.body, input.signature, input.secret), verifySignature: (input: VerifyWebhookSignatureInput) => diff --git a/packages/typescript/src/webhooks/verify.ts b/packages/typescript/src/webhooks/verify.ts index 06dc991a..52f715c2 100644 --- a/packages/typescript/src/webhooks/verify.ts +++ b/packages/typescript/src/webhooks/verify.ts @@ -52,6 +52,10 @@ export async function constructWebhookEvent( } export function parseVerifiedWebhookEvent(rawBody: WebhookBody): WebhookEvent { + return parseEventEnvelope(decodeVerifiedWebhookBody(rawBody)); +} + +export function decodeVerifiedWebhookBody(rawBody: WebhookBody): unknown { let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode( @@ -80,6 +84,10 @@ export function parseVerifiedWebhookEvent(rawBody: WebhookBody): WebhookEvent { ); } + return parsed; +} + +export function parseEventEnvelope(parsed: unknown): WebhookEvent { if (!isEventEnvelope(parsed)) { throw new PolymorfaValidationError( "Webhook body is not a valid event envelope.", diff --git a/packages/typescript/test/cloud-marketing.test.ts b/packages/typescript/test/cloud-marketing.test.ts index f0e86a6b..6824a154 100644 --- a/packages/typescript/test/cloud-marketing.test.ts +++ b/packages/typescript/test/cloud-marketing.test.ts @@ -10,7 +10,14 @@ describe("Official API marketing status", () => { it("returns Meta's raw WABA status strings from the exact Graph route", async () => { const payload = { id: "102290129340398", - marketing_messages_lite_api_status: "ELIGIBLE", + marketing_messages_onboarding_status: "ELIGIBLE", + owner_business_info: { + marketing_messages_onboarding_status: { + status: "REQUEST_SENT", + time: "2026-10-21", + }, + }, + disable_marketing_messages_on_cloud_api: true, }; const server = await startTestServer(() => ({ body: JSON.stringify(payload), diff --git a/packages/typescript/test/error-body.test.ts b/packages/typescript/test/error-body.test.ts index becd064a..051dca49 100644 --- a/packages/typescript/test/error-body.test.ts +++ b/packages/typescript/test/error-body.test.ts @@ -189,7 +189,14 @@ describe("error codes", () => { for (const code of documentedInProse) { expect(platformText).toContain(`code \`${code}\``); } + const round2 = JSON.parse( + await readFile( + new URL("../../../contracts/official-api-round2.json", import.meta.url), + "utf8", + ), + ) as { errorCodes: string[] }; const published = new Set([ + ...round2.errorCodes, ...messaging.components.schemas.PublicError!.properties.error.properties .code.enum, ...platform.components.schemas.PlatformAccessPublicError!.properties.error diff --git a/packages/typescript/test/official-api-round2-contracts.test.ts b/packages/typescript/test/official-api-round2-contracts.test.ts new file mode 100644 index 00000000..6cea0261 --- /dev/null +++ b/packages/typescript/test/official-api-round2-contracts.test.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; +const file = (p: string) => + readFileSync(new URL(`../../../${p}`, import.meta.url)); +it("pins each round-2 contract subset to its exact merge and leaves publication held", () => { + const pin = JSON.parse( + file("contracts/official-api-round2.json").toString(), + ) as { + published: boolean; + sourceCommit: string; + errorCodes: string[]; + items: { + item: string; + mergeSha: string; + snapshotPath: string; + snapshotSha256: string; + sourceSha256: string; + releaseSha256: string; + }[]; + }; + expect(pin.published).toBe(false); + expect(pin.sourceCommit).toBe("9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f"); + expect(pin.items.map((x) => x.item)).toEqual([ + "OA-03", + "OA-16", + "OA-17", + "OA-18", + "OA-21", + "OA-30", + ]); + const sourceErrorCodes = new Set(); + for (const item of pin.items) { + expect(item.mergeSha).toMatch(/^[a-f0-9]{40}$/); + expect(item.sourceSha256).toMatch(/^[a-f0-9]{64}$/); + expect(item.releaseSha256).toMatch(/^[a-f0-9]{64}$/); + expect( + createHash("sha256").update(file(item.snapshotPath)).digest("hex"), + ).toBe(item.snapshotSha256); + const spec = JSON.parse(file(item.snapshotPath).toString()) as { + paths: Record; + components: { + schemas: { + PublicError?: { + properties: { error: { properties: { code: { enum: string[] } } } }; + }; + }; + }; + }; + for (const code of spec.components.schemas.PublicError?.properties.error + .properties.code.enum ?? []) + sourceErrorCodes.add(code); + // These catalog errors are documented in operation responses rather than + // the shared PublicError enum at the OA-16 merge. + for (const code of ["catalog_cache_full", "catalog_sync_cooldown"]) + if (JSON.stringify(spec.paths).includes(code)) sourceErrorCodes.add(code); + expect(Object.keys(spec.paths).some((p) => p.startsWith("/console/"))).toBe( + false, + ); + } + for (const code of pin.errorCodes) + expect(sourceErrorCodes.has(code), code).toBe(true); +}); diff --git a/packages/typescript/test/official-api-round2.test.ts b/packages/typescript/test/official-api-round2.test.ts new file mode 100644 index 00000000..b9ef7974 --- /dev/null +++ b/packages/typescript/test/official-api-round2.test.ts @@ -0,0 +1,314 @@ +import { createHmac } from "node:crypto"; +import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { + Client, + MessagingClient, + constructWebhookDelivery, + constructWebhookEvent, + isWebhookEventBatch, + webhooks, + type ContactUpdatePayload, + type IncomingOrderCart, + type HistoryMessage, + type WebhookPayloadMap, +} from "../src/index.js"; +import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; + +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())); +}); +async function setup( + status = 200, + data: unknown = { success: true, data: {} }, +) { + const server = await startTestServer(() => ({ + status, + body: JSON.stringify(data), + })); + servers.push(server); + const client = new MessagingClient({ + baseUrl: server.url, + credential: { type: "projectToken", value: PROJECT_TOKEN }, + maxNetworkRetries: 2, + }); + return { server, client }; +} +const event = { + id: "evt-1", + session: "support", + timestamp: "2026-10-21T10:00:00Z", + event: "message.received", + payload: {}, +}; +const batch = { + object: "event_batch", + version: 1, + id: "00000000-0000-4000-8000-000000000001", + events: [event], +}; +async function decode(value: unknown) { + const raw = JSON.stringify(value); + return constructWebhookDelivery( + raw, + createHmac("sha256", "secret").update(raw).digest("hex"), + "secret", + ); +} + +describe("round-2 customer routes", () => { + it("uses native catalog routes and opaque IDs/cursors, separate from Graph reads", async () => { + const { client, server } = await setup(); + await client.catalogs.list("number/eu"); + await client.catalogs.retrieve( + "number/eu", + "12345678901234567890123456789012", + ); + await client.catalogs.sync("number/eu", "123"); + await client.catalogs.listProducts("number/eu", "123", { + limit: 100, + cursor: "opaque_-", + }); + await client.cloudMarketing.getStatus("number/eu"); + await client.contacts.block("number/eu", "123"); + await client.contacts.unblock("number/eu", "123"); + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "GET /messaging/number%2Feu/catalogs", + "GET /messaging/number%2Feu/catalogs/12345678901234567890123456789012", + "POST /messaging/number%2Feu/catalogs/123/sync", + "GET /messaging/number%2Feu/catalogs/123/products?limit=100&cursor=opaque_-", + "GET /messaging/number%2Feu/marketing-messages/status", + "POST /messaging/number%2Feu/contacts/123/block", + "POST /messaging/number%2Feu/contacts/123/unblock", + ]); + }); + it("rejects invalid bounds and client credentials before transport", async () => { + const { client, server } = await setup(); + expect(() => client.catalogs.retrieve("support", "../123")).toThrow( + "1 to 32 digits", + ); + expect(() => + client.catalogs.listProducts("support", "123", { limit: 101 }), + ).toThrow("1 to 100"); + expect(() => + client.catalogs.listProducts("support", "123", { cursor: "../" }), + ).toThrow("nextCursor"); + expect(() => client.cloudMarketing.getStatus("")).toThrow("Number ID"); + const browser = new MessagingClient({ + baseUrl: server.url, + credential: { type: "clientToken", value: `pmfa_ct_${"A".repeat(94)}` }, + }); + expect(() => browser.catalogs.list("support")).toThrow( + "organization API key", + ); + expect(() => browser.cloudMarketing.getStatus("support")).toThrow( + "organization API key", + ); + expect(server.requests).toHaveLength(0); + }); + it("surfaces feature_unavailable for every new native read/write without fallback", async () => { + const { client, server } = await setup(403, { + error: { code: "feature_unavailable", message: "Beta unavailable" }, + }); + const calls = [ + () => client.catalogs.list("n"), + () => client.catalogs.retrieve("n", "1"), + () => client.catalogs.sync("n", "1"), + () => client.catalogs.listProducts("n", "1"), + () => client.cloudMarketing.getStatus("n"), + () => + client.officialGroups.setPicture( + "n", + "1", + new Blob(["jpeg"], { type: "image/jpeg" }), + ), + ]; + for (const call of calls) + await expect(call()).rejects.toMatchObject({ + status: 403, + code: "feature_unavailable", + }); + expect(server.requests).toHaveLength(calls.length); + }); + it.each(["catalog_sync_cooldown", "upstream_failure"])( + "never replays an explicit sync after %s, even with an idempotency key", + async (code) => { + const { client, server } = await setup( + code === "catalog_sync_cooldown" ? 429 : 502, + { error: { code, message: code } }, + ); + await expect( + client.catalogs.sync("n", "1", { + idempotencyKey: "sync-1", + maxNetworkRetries: 4, + }), + ).rejects.toMatchObject({ code }); + expect(server.requests).toHaveLength(1); + }, + ); + it("uploads the Official picture as multipart bytes and never sends a JSON file object", async () => { + const { client, server } = await setup(); + await client.officialGroups.setPicture( + "n/eu", + "g/1", + new Blob(["jpeg-fixture"], { type: "image/jpeg" }), + ); + const request = server.requests[0]!; + expect(request.method).toBe("PUT"); + expect(request.path).toBe( + "/messaging/n%2Feu/official-groups/g%2F1/picture", + ); + expect(request.headers["content-type"]).toMatch( + /^multipart\/form-data; boundary=/, + ); + expect(request.body).toContain('name="file"; filename="picture.jpg"'); + expect(request.body).toContain("Content-Type: image/jpeg"); + expect(request.body).toContain("jpeg-fixture"); + expect(() => + client.officialGroups.setPicture( + "n", + "g", + new Blob([], { type: "image/jpeg" }), + ), + ).toThrow("JPEG"); + expect(() => + client.officialGroups.setPicture( + "n", + "g", + new Blob(["png"], { type: "image/png" }), + ), + ).toThrow("JPEG"); + }); + it("keeps Hybrid groups on the native Linked Devices route and retains the not-executed error", async () => { + const { client, server } = await setup(409, { + error: { + code: "hybrid_group_linked_devices_unavailable", + message: "Not executed", + }, + }); + await expect(client.groups.retrieve("hybrid", "123")).rejects.toMatchObject( + { code: "hybrid_group_linked_devices_unavailable" }, + ); + expect( + server.requests.every((r) => !r.path.includes("official-groups")), + ).toBe(true); + }); + it("sends batching only on Platform endpoint management and retains feature errors", async () => { + const { server } = await setup(403, { + error: { code: "feature_unavailable", message: "Beta unavailable" }, + }); + const client = new Client({ + baseUrl: server.url, + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + }); + const input = { enabled: true, maxEvents: 2, windowMs: 100 }; + await expect( + client.webhooks.update("w/1", { batching: input }), + ).rejects.toMatchObject({ code: "feature_unavailable" }); + expect(server.requests[0]!.path).toBe("/platform/webhooks/w%2F1"); + expect(JSON.parse(server.requests[0]!.body)).toEqual({ batching: input }); + expect(() => + client.webhooks.update("w", { + batching: { enabled: true, maxEvents: 1 }, + }), + ).toThrow("2 to 100"); + expect(() => + client.webhooks.update("w", { + batching: { enabled: true, windowMs: 1001 }, + }), + ).toThrow("100 to 1000"); + expect(() => + client.webhooks.create({ + url: "https://example.invalid", + eventTypes: [], + format: "meta", + batching: { enabled: true }, + }), + ).toThrow("native format"); + }); +}); + +describe("signed webhook batches", () => { + it("accepts a one-member window, unknown events, original order and member IDs", async () => { + const value = { + ...batch, + events: [event, { ...event, id: "evt-2", event: "future.event" }], + }; + expect(await decode(value)).toEqual(value); + expect(await decode(batch)).toEqual(batch); + const fixture = await webhooks.createFixture({ + event: await decode(value), + secret: "secret", + }); + expect( + await webhooks.verifyDelivery({ + body: fixture.body, + signature: fixture.signature, + secret: "secret", + }), + ).toEqual(value); + }); + it("keeps single deliveries and the single-only API unchanged", async () => { + expect(await decode(event)).toEqual(event); + const raw = JSON.stringify(batch); + const signature = createHmac("sha256", "secret").update(raw).digest("hex"); + await expect( + constructWebhookEvent(raw, signature, "secret"), + ).rejects.toMatchObject({ code: "invalid_webhook_event" }); + }); + it("authenticates the entire body before parsing, including the batch ID and every member", async () => { + const raw = JSON.stringify(batch); + const signature = createHmac("sha256", "secret").update(raw).digest("hex"); + await expect( + constructWebhookDelivery( + raw.replace("evt-1", "evt-2"), + signature, + "secret", + ), + ).rejects.toMatchObject({ code: "invalid_webhook_signature" }); + await expect( + constructWebhookDelivery("{", signature, "secret"), + ).rejects.toMatchObject({ code: "invalid_webhook_signature" }); + }); + it.each([ + { ...batch, version: 2 }, + { ...batch, id: "bad" }, + { ...batch, events: [] }, + { ...batch, events: Array(101).fill(event) }, + { ...batch, events: [event, {}] }, + { ...batch, events: [batch] }, + { ...batch, extra: true }, + ])( + "rejects malformed batches without returning partial members", + async (value) => { + await expect(decode(value)).rejects.toThrow(); + }, + ); + it("types incoming carts, lifecycle and separate previous identities", async () => { + expectTypeOf().toEqualTypeOf< + IncomingOrderCart | undefined + >(); + expectTypeOf< + WebhookPayloadMap["marketing.onboarding_updated"]["kind"] + >().toEqualTypeOf<"terms_signed" | "ad_account_linked">(); + const contact: ContactUpdatePayload = { + id: "new", + parentBsuid: "US.ENT.metadata", + identityChange: { + reason: "user_id_changed", + messageId: "meta1", + previous: { id: "old", bsuid: "old-bsuid" }, + }, + }; + const decoded = await decode({ + ...event, + event: "contact.update", + payload: contact, + }); + expect(isWebhookEventBatch(decoded)).toBe(false); + expect(decoded).toMatchObject({ + payload: { id: "new", identityChange: { previous: { id: "old" } } }, + }); + }); +}); diff --git a/packages/typescript/test/webhook-contract.test.ts b/packages/typescript/test/webhook-contract.test.ts index 668bb15a..e1e4fea9 100644 --- a/packages/typescript/test/webhook-contract.test.ts +++ b/packages/typescript/test/webhook-contract.test.ts @@ -41,6 +41,12 @@ const load = (family: "messaging" | "platform") => JSON.parse( readFileSync(resolve(root, `contracts/openapi.${family}.json`), "utf8"), ) as OpenApiDocument; +const round2Marketing = JSON.parse( + readFileSync( + resolve(root, "contracts/official-api-round2/oa-17.json"), + "utf8", + ), +) as OpenApiDocument; const baselineMessaging = load("messaging"); const abpropsContract = JSON.parse( readFileSync(resolve(root, "contracts/abprops-capabilities.json"), "utf8"), @@ -50,6 +56,14 @@ const messaging: OpenApiDocument = { components: { schemas: { ...baselineMessaging.components.schemas, + ...Object.fromEntries( + [ + "MarketingOnboardingUpdatedEvent", + "ContactMarketingPreferenceUpdatedEvent", + "MarketingOnboardingUpdatedPayload", + "ContactMarketingPreferencePayload", + ].map((name) => [name, round2Marketing.components.schemas[name]!]), + ), SessionCapabilitiesUpdatedPayload: abpropsContract.components.schemas.SessionCapabilitiesUpdatedPayload!, SessionCapabilitiesUpdatedEvent: diff --git a/packages/typescript/test/webhook-event-types.test.ts b/packages/typescript/test/webhook-event-types.test.ts index c241391c..3318394f 100644 --- a/packages/typescript/test/webhook-event-types.test.ts +++ b/packages/typescript/test/webhook-event-types.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { BlocklistUpdatePayload, + IncomingOrderCart, WhatsAppMessageIds, BusinessQuickReplyUpdatePayload, CallAcceptedPayload, @@ -54,6 +55,7 @@ import type { import { KNOWN_WEBHOOK_EVENT_TYPES } from "../src/index.js"; type ExpectedIdentityReference = { + readonly parentBsuid?: string; readonly id: string; readonly phoneNumber?: string; readonly bsuid?: string; @@ -105,6 +107,7 @@ type ExpectedLinkedDeviceMessageType = | "unknown"; type ExpectedMessagePayload = { + readonly order?: IncomingOrderCart; readonly id: string; readonly whatsapp_ids: WhatsAppMessageIds; readonly whatsapp_id?: string; @@ -289,6 +292,16 @@ type ExpectedPayloads = { readonly error?: string; }; readonly "contact.update": { + readonly parentBsuid?: string; + readonly identityChange?: { + readonly reason: "phone_number_changed" | "user_id_changed"; + readonly messageId: string; + readonly previous?: { + readonly id?: string; + readonly bsuid?: string; + readonly parentBsuid?: string; + }; + }; readonly id: string; readonly phoneNumber?: string; readonly bsuid?: string; diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index b9da8b90..f267cb9d 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -78,6 +78,9 @@ export function expectedContract() { readFileSync(join(root, "contracts/client-token-ceilings.json")), ), ]); + validateFeatureContractPins(source, [ + JSON.parse(readFileSync(join(root, "contracts/official-api-round2.json"))), + ]); const apiVersion = /NATIVE_API_VERSION = "([^"]+)"/.exec( readFileSync(join(root, "packages/typescript/src/version.ts"), "utf8"), )?.[1];