{"openapi": "3.1.2", "info": {"title": "Funnelchat Accounts — API", "version": "0.1.0", "description": "La API pública de **Funnelchat Accounts**: los números de WhatsApp de la cuenta\n(devices), sus alertas operativas, los dominios propios y los datos de la cuenta.\n\n### Cómo autenticarse\n\nCada llamada viaja con la clave de API en el header `Authorization`:\n\n```\nAuthorization: Bearer <id>|fc_live_...\n```\n\nLas claves se emiten y se revocan desde la pantalla **Desarrolladores** del panel.\nEl valor completo se muestra **una sola vez**, al crearla.\n\n### Permisos\n\nCada ruta de esta documentación declara el permiso que exige. La clave sólo puede\nllamar las rutas para las que se le otorgó el permiso al crearla:\n\n| Permiso | Qué habilita |\n|---|---|\n| `accounts:read` | Leer. Todas las rutas `GET` salvo las de las suscripciones unificadas. |\n| `accounts:write` | Modificar de forma reversible. |\n| `accounts:operate` | Las cinco operaciones **irreversibles** (reset, limpieza, purga de cola y los dos borrados). |\n| `webhooks:read` | Leer las suscripciones de webhook unificadas y sus entregas. |\n| `webhooks:write` | Crear, modificar, probar y borrar suscripciones. Implica `webhooks:read`. |\n\n`accounts:operate` es un eje aparte, no un escalón más alto: `accounts:write` **no**\nhabilita las irreversibles, y `accounts:operate` no habilita nada más.\n\n`webhooks:*` y `accounts:*` no se implican entre sí. Además, para crear o modificar una\nsuscripción con una clave, la clave necesita el permiso de lectura del área de cada tipo\nde evento (`accounts:read`, `conversations:read` o `communities:read`; `write` o `send`\nde esa misma área también sirven); si le falta alguno, responde `422`.\n\nLeer, cambiar, borrar o probar una suscripción con una clave sigue la misma regla de\náreas: para verla (`GET api/v1/webhooks/{id}`), ver su historial de entregas\n(`GET api/v1/webhooks/{id}/deliveries`), cambiarla (`PUT`), borrarla (`DELETE`) o\nprobarla (`POST api/v1/webhooks/{id}/test`), la clave necesita el permiso de lectura\ndel área de cada tipo de evento que la suscripción tiene ahora; si le falta alguno\nresponde `403 permission_denied`, el mensaje nombra el permiso que falta y no se\ncambia, borra ni envía nada. El listado (`GET api/v1/webhooks`) muestra solo las\nsuscripciones que la clave puede leer, y el historial muestra solo las entregas de\ntipos cuya área la clave puede leer (si la suscripción estuvo suscripta antes a otra\nárea, esas filas no aparecen); las entregas de prueba (`webhook.test`) se muestran\nsiempre.\n\n### Lo que no está acá no existe para una clave\n\nLa superficie es cerrada. Una ruta que no figure en esta documentación responde\n`403 route_not_available` a cualquier clave, sin importar qué permisos tenga — y eso\nincluye la facturación, la cuota de IA y la propia pantalla de claves.\n\n### Webhooks\n\nLas suscripciones se administran en `api/v1/webhooks`, el sistema unificado: cada\nsuscripción tiene un solo secreto y un solo historial de entregas, y recibe eventos de\nlas tres áreas (`accounts`, `conversations` y `communities`). Rotar el secreto y\n«Activar» una suscripción migrada se hacen desde el panel. `api/v1/webhooks/endpoints`\nes el sistema anterior y sigue disponible con los permisos `accounts:*`.\n\nUna suscripción queda atada a la clave de API que la creó, o a la última clave que\n**cambió su dirección** (`url`), aunque la haya creado el panel u otra clave: así, si\nuna clave robada redirige una suscripción, revocarla corta ese destino. Cuando se\nrevoca la clave a la que está atada, la suscripción deja de recibir eventos de\ninmediato y pasa a `status: disabled` con `disabled_reason: key_revoked`. No se puede\nvolver a habilitar: hay que crear una suscripción nueva. Cambiar la dirección desde el\npanel no la desata de la clave. Una suscripción que no está atada a ninguna clave\n(creada desde el panel y sin que ninguna clave le haya cambiado la dirección) es de la\ncuenta: revocar claves no la afecta.\n\nEl catálogo (`GET api/v1/webhooks/events`) lista todos los eventos de las tres áreas\n(`accounts`, `conversations` y `communities`). En cada entrada, `status` dice si el\nevento está habilitado (`active`) o si se puede suscribir pero todavía no emite\n(`planned`), y `example` trae el cuerpo completo que vas a recibir. Al final de esta\npágina, en «Ejemplos de payload de webhooks», están los ejemplos de los eventos\nactivos del área `accounts` a los que te podés suscribir; los de `conversations` y\n`communities` están en el catálogo.\n\nSobre los dos eventos de los números de la cuenta:\n\n- `device.connected`: un número de la cuenta quedó conectado. Cubre dos caminos: la\n  conexión por código QR y el alta de un número de WhatsApp Cloud. No se emite por un\n  cambio manual hecho por el soporte de Funnelchat, ni cuando se bloquea un número de\n  prueba que ya se usó en otra cuenta, ni dos veces por la misma conexión.\n- `device.disconnected`: un número conectado por código QR perdió la conexión. Los\n  números de WhatsApp Cloud todavía no emiten este evento.\n\n#### Sobre\n\nCada entrega es un `POST` con cuerpo JSON `{id, type, version, occurred_at, data}`:\n`id` identifica el hecho (es el mismo en todos los reintentos y en todos tus endpoints\nsuscriptos al mismo evento, así que sirve para deduplicar), `type` es el nombre del\nevento, `version` es la versión de ese tipo de evento y `occurred_at` es UTC.\n\n#### Cabeceras\n\n- `X-Funnelchat-Signature`: la firma (ver abajo).\n- `X-Funnelchat-Event`: el tipo de evento.\n- `X-Funnelchat-Delivery`: un UUID que cambia en cada intento de entrega.\n\n#### Firma\n\nLa firma es `t=<unix>,v1=<hex>`, donde `v1` es el HMAC-SHA256 de `\"<t>.<cuerpo crudo>\"`\ncon el secreto del endpoint (completo, con el prefijo `whsec_`). Verificá sobre el cuerpo\n**crudo**, antes de parsear el JSON, y rechazá las entregas con más de 300 segundos de\ndiferencia. Más detalle en `/docs/webhooks/#firma`.\n\nPHP:\n\n```php\nfunction verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool\n{\n    parse_str(str_replace(',', '&', $cabecera), $partes);\n\n    if (! isset($partes['t'], $partes['v1']) || ! ctype_digit($partes['t'])) {\n        return false;\n    }\n\n    if (abs(time() - (int) $partes['t']) > $tolerancia) {\n        return false;\n    }\n\n    $esperada = hash_hmac('sha256', $partes['t'].'.'.$cuerpoCrudo, $secreto);\n\n    return hash_equals($esperada, $partes['v1']);\n}\n```\n\nNode:\n\n```js\nconst crypto = require('crypto');\n\nfunction verificarFirma(cuerpoCrudo, cabecera, secreto, tolerancia = 300) {\n  const partes = Object.fromEntries(cabecera.split(',').map((p) => p.split('=')));\n  if (!/^\\d+$/.test(partes.t || '') || !partes.v1) return false;\n\n  if (Math.abs(Date.now() / 1000 - Number(partes.t)) > tolerancia) return false;\n\n  const esperada = crypto\n    .createHmac('sha256', secreto)\n    .update(`${partes.t}.${cuerpoCrudo}`)\n    .digest('hex');\n\n  const a = Buffer.from(esperada);\n  const b = Buffer.from(partes.v1);\n\n  return a.length === b.length && crypto.timingSafeEqual(a, b);\n}\n```\n\n#### Reintentos\n\nUna entrega es exitosa con cualquier `2xx`. Ante un error de red, un `5xx` o un `429` se\nreintenta hasta 4 intentos en total, esperando 60 segundos, 5 minutos y 30 minutos entre\nellos. Cualquier otra respuesta (`3xx`, `4xx` salvo `429`) no se reintenta. Un endpoint que\nacumula 5 entregas fallidas consecutivas (agotadas o con una respuesta que no se\nreintenta) se deshabilita automáticamente.\n\n#### Probar una suscripción\n\n`POST api/v1/webhooks/{id}/test` envía una sola entrega firmada de `webhook.test`, sin\nreintentos, a una suscripción `active` o `paused`, y la registra en su historial. Si la\nsuscripción está `disabled` responde `422` sin enviar ni registrar nada: si se\ndeshabilitó por entregas fallidas, reactivala (`PUT` con `status: active`) y probala\ndespués; si se revocó la clave a la que estaba atada (`key_revoked`), creá una\nsuscripción nueva. Con una clave, la regla de áreas de «Permisos» va primero: si falta\nun permiso responde `403`, aunque la suscripción esté `disabled`.\n\n\n### Listados\n\nCon una clave, todo listado pagina. `perPage` va de 1 a 100 y vale 25 si no se\nenvía; más de 100 (o algo que no sea un entero) responde `422`. Algunas rutas\naceptan además su nombre histórico (`per_page`, `pageSize`, `limit`) con el\nmismo tope. El orden es fijo en cada ruta y está indicado en ella: ningún\nlistado acepta parámetros de ordenamiento (`orderBy`, `sort`, …) y enviar uno\nresponde `422`. La sesión web del panel no cambia.\n\n### Estado: v0\n\nEsta API está en **v0**: puede cambiar sin aviso previo.\n\n- Algunas respuestas de rutas existentes incluyen identificadores internos\n  (`user_id` en los devices, `owner_id` en los datos de la cuenta). No los uses\n  como claves estables.\n- Las suscripciones de webhook atadas a una clave (creadas o redirigidas con ella)\n  mueren con ella (ver «Webhooks»).\n\n<!-- webhook-examples -->\n### Ejemplos de payload de webhooks\n\n`device.disconnected`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"device.disconnected\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-05T03:00:00Z\",\n    \"data\": {\n        \"device\": {\n            \"id\": 4521,\n            \"name\": \"Ventas\",\n            \"number\": \"5491100000000\"\n        },\n        \"disconnected_at\": \"2026-10-05T03:00:00+00:00\"\n    }\n}\n```\n\n`device.connected`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"device.connected\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-05T03:00:00Z\",\n    \"data\": {\n        \"device\": {\n            \"id\": 4521,\n            \"name\": \"Ventas\",\n            \"number\": \"5491100000000\"\n        },\n        \"connected_at\": \"2026-10-05T03:00:00+00:00\"\n    }\n}\n```\n\n`billing.payment_failed`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"billing.payment_failed\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"amount_cents\": 2990,\n        \"currency\": \"USD\"\n    }\n}\n```\n\n`billing.upcoming_charge`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"billing.upcoming_charge\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"charge_date\": \"2026-11-05T00:00:00Z\",\n        \"amount_cents\": 2990,\n        \"currency\": \"USD\"\n    }\n}\n```\n\n`subscription.trial_ending`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"subscription.trial_ending\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"trial_ends_at\": \"2026-10-04T00:00:00Z\"\n    }\n}\n```\n\n`subscription.reactivated`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"subscription.reactivated\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"renews_at\": \"2026-11-05T00:00:00Z\"\n    }\n}\n```\n\n`subscription.cancellation_confirmed`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"subscription.cancellation_confirmed\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"access_until\": \"2026-11-05T00:00:00Z\"\n    }\n}\n```\n\n`subscription.dormant_ending`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"subscription.dormant_ending\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"charge_date\": \"2026-10-08T00:00:00Z\",\n        \"amount_cents\": 1990,\n        \"currency\": \"USD\"\n    }\n}\n```\n\n`mac.usage_80`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"mac.usage_80\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"used\": 8000,\n        \"limit\": 10000,\n        \"percentage\": 80,\n        \"cycle_started_at\": \"2026-10-01T00:00:00Z\"\n    }\n}\n```\n\n`mac.usage_100`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"mac.usage_100\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"used\": 10000,\n        \"limit\": 10000,\n        \"percentage\": 100,\n        \"cycle_started_at\": \"2026-10-01T00:00:00Z\",\n        \"grace_expires_at\": \"2026-10-02T12:00:00Z\"\n    }\n}\n```\n\n`mac.expiring`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"mac.expiring\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"contacts_expiring\": 1200,\n        \"expires_at\": \"2026-10-08T03:00:00Z\"\n    }\n}\n```\n\n`account.blocked`:\n\n```json\n{\n    \"id\": \"evt_0f8e4b526c1d4c7a9a3e2b5d\",\n    \"type\": \"account.blocked\",\n    \"version\": 1,\n    \"occurred_at\": \"2026-10-01T12:00:00Z\",\n    \"data\": {\n        \"reason\": \"mac_grace_expired\",\n        \"grace_started_at\": \"2026-10-01T12:00:00Z\",\n        \"grace_expired_at\": \"2026-10-02T12:00:00Z\"\n    }\n}\n```\n"}, "servers": [{"url": "https://api.funnelchat.com/api/v1"}], "security": [{"http": []}], "paths": {"/me": {"get": {"description": "**Lectura** · requiere el permiso `accounts:read`.", "responses": {"200": {"description": "`UserResource`", "content": {"application/json": {"schema": {"allOf": [{"$ref": "#/components/schemas/UserResource"}, {"type": "object", "required": ["feature_versions"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/alerts/campaigns": {"get": {"operationId": "alertCampaign.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: id ascendente · no acepta parámetros de orden.", "summary": "List campaigns visible to the authenticated account (owner + agents\nscope), reading from the read-only `groups` connection. Paginated,\nsearchable by `name`. Degrades to 503 when the `groups` connection is\nunavailable instead of leaking a 500 / stack trace", "tags": ["AlertCampaign"], "parameters": [{"name": "page", "in": "query", "schema": {"type": ["integer", "null"], "minimum": 1}}, {"name": "per_page", "in": "query", "schema": {"type": ["integer", "null"], "minimum": 1, "maximum": 100}}, {"name": "search", "in": "query", "schema": {"type": ["string", "null"], "maxLength": 100}}], "responses": {"200": {"description": "Paginated set of `AlertCampaignResource`", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/AlertCampaignResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}}}}, "503": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "No se pudieron cargar las campañas en este momento."}, "error": {"type": "string", "const": "groups_unavailable"}}, "required": ["message", "error"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "per_page"], "order": "id ascendente", "sortable": []}}}, "/alerts/catalog": {"get": {"operationId": "alertCatalog.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\nStripe-only alerts (concepts/events that only exist for accounts billed\nthrough Stripe, e.g. subscription reactivation, dormant period ending)\nare excluded when the owner's payment_provider is not Stripe (Hotmart\nor null).", "summary": "Return the alert catalog, filtered by the owner's payment provider", "tags": ["AlertCatalog"], "responses": {"200": {"description": "Array of `AlertCatalogResource`", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/AlertCatalogResource"}}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/alerts/daily-report": {"get": {"operationId": "alertDailyReport.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "summary": "Return the daily report configuration for the authenticated owner.\nReturns a default structure if no record exists yet", "tags": ["AlertDailyReport"], "responses": {"200": {"description": "`AlertDailyReportResource`", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AlertDailyReportResource"}, {"type": "object", "properties": {"enabled": {"type": "boolean"}, "send_time": {"type": "string", "const": "08:00"}, "all_campaigns": {"type": "boolean"}, "campaign_ids": {"type": "array", "items": {"type": "string"}, "minItems": 0, "maxItems": 0}}, "required": ["enabled", "send_time", "all_campaigns", "campaign_ids"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "put": {"operationId": "alertDailyReport.update", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nWhen `all_campaigns` is false, the submitted `campaign_ids` are pruned\nto only those that still resolve to a visible campaign (owner + agents\nscope) in the `groups` database, dropping orphans silently. The purge\nis best-effort: if the `groups` connection is unavailable, the\nsubmitted ids are persisted unpruned rather than being wiped out.", "summary": "Upsert the daily report configuration for the authenticated owner.\nEnsures exactly one row per owner (unique(owner_id))", "tags": ["AlertDailyReport"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAlertDailyReportRequest"}}}}, "responses": {"200": {"description": "`AlertDailyReportResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertDailyReportResource"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations": {"get": {"operationId": "alertDestination.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.", "summary": "List destinations for the authenticated owner.\nSeed lazy: if the owner has no destinations and has a phone number,\ncreate an unverified phone destination from it", "tags": ["AlertDestination"], "responses": {"200": {"description": "Array of `AlertDestinationResource`\n\nPaginated set of `AlertDestinationResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "array", "items": {"$ref": "#/components/schemas/AlertDestinationResource"}}, {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/AlertDestinationResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}, "post": {"operationId": "alertDestination.store", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nEnforces, para `webhook`:\n  - tope de {@see self::WEBHOOK_DESTINATION_LIMIT} destinos por cuenta;\n  - `SsrfGuard::assertSafe()` ANTES de persistir. Hasta este incremento el\n    unico chequeo en el alta era `str_starts_with($destination, 'https://')`,\n    y el guard corria solo en el boton de prueba manual: se podia guardar\n    tranquilamente un destino apuntando a `https://127.0.0.1/...` o al\n    endpoint de metadata de la nube, y quedaba ahi esperando a que el\n    transporte de entrega lo usara;\n  - `verified_at` en null. Un webhook NO se auto-verifica: hay que pasar\n    por `POST alerts/destinations/{id}/verify`, que exige que el destino\n    devuelva un challenge firmado (ver AlertWebhookVerificationController);\n  - generacion del secreto de firma, devuelto en texto plano UNA sola vez\n    en esta respuesta 201 y nunca mas.", "summary": "Create a new destination for the authenticated owner", "tags": ["AlertDestination"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreAlertDestinationRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations/{id}": {"patch": {"operationId": "alertDestination.update", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nLa URL de un `phone` sigue siendo de solo lectura post-creacion (se\nverifica por OTP contra ese numero; cambiarlo seria crear otro destino).\nPara un `webhook` se permite corregirla, y esa es justamente la razon por\nla que el guard anti-SSRF tiene que correr TAMBIEN aca: si solo corriera\nen el alta, editar seria la puerta de atras para dejar persistida una URL\nque apunta a la red interna.\n\nCambiar la URL resetea `verified_at`: lo que se verifico fue un endpoint\nconcreto, no el registro.\n\nReturns 404 for both non-existent and other-owner destinations to prevent\nresource-existence enumeration by unauthenticated/unauthorized callers.", "summary": "Update the name of a destination, y —solo para `webhook`— su URL", "tags": ["AlertDestination"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAlertDestinationRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}, "delete": {"operationId": "alertDestination.destroy", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nReturns 404 for both non-existent and other-owner destinations to prevent\nresource-existence enumeration by unauthenticated/unauthorized callers.", "summary": "Delete a destination (settings cascade via DB constraint)", "tags": ["AlertDestination"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "No content"}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Destino no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations/{id}/otp": {"post": {"operationId": "alertOtp.send", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nHTTP 202: OTP dispatched.\nHTTP 422: destination is not a phone or is already verified.\nHTTP 429: cooldown active or hourly limit reached.", "summary": "Send (or resend) an OTP to a phone destination", "tags": ["AlertOtp"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"202": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Código enviado."}}, "required": ["message"]}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"]}}}}, "429": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"message": {"type": "string", "const": "Alcanzaste el límite de reenvíos por hora. Intenta más tarde."}}, "required": ["message"]}, {"type": "object", "properties": {"message": {"type": "string", "const": "Debes esperar antes de solicitar un nuevo código."}, "retry_after_seconds": {"type": "integer"}}, "required": ["message", "retry_after_seconds"]}]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Destino no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations/{id}/otp/verify": {"post": {"operationId": "alertOtp.verify", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nHTTP 200: verified — returns updated destination resource.\nHTTP 422: destination not a phone, already verified, or wrong/expired code.", "summary": "Verify the OTP code submitted by the user", "tags": ["AlertOtp"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerifyAlertOtpRequest"}}}}, "responses": {"200": {"description": "`AlertDestinationResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertDestinationResource"}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Destino no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/quiet-hours": {"get": {"operationId": "alertQuietHour.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "summary": "Return the quiet hours configuration for the authenticated owner.\nReturns an empty/default structure if no record exists yet", "tags": ["AlertQuietHour"], "responses": {"200": {"description": "`AlertQuietHourResource`", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AlertQuietHourResource"}, {"type": "object", "properties": {"enabled": {"type": "boolean"}, "start_time": {"type": "null"}, "end_time": {"type": "null"}}, "required": ["enabled", "start_time", "end_time"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "put": {"operationId": "alertQuietHour.update", "description": "**Escritura** · requiere el permiso `accounts:write`.", "summary": "Upsert the quiet hours configuration for the authenticated owner.\nEnsures exactly one row per owner (unique(owner_id))", "tags": ["AlertQuietHour"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAlertQuietHourRequest"}}}}, "responses": {"200": {"description": "`AlertQuietHourResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertQuietHourResource"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations/{id}/settings": {"put": {"operationId": "alertSetting.upsert", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\n- Validates that the module/action pair exists in AlertCatalog.\n- Validates that the destination is verified (verified_at not null).\n- Enforces unique(alert_destination_id, module, action) via updateOrCreate.\n- Owner-scoped: returns 404 for both non-existent and other-owner destinations\n  to prevent resource-existence enumeration.", "summary": "Upsert an alert setting (toggle on/off) for a destination", "tags": ["AlertSetting"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpsertAlertSettingRequest"}}}}, "responses": {"200": {"description": "`AlertSettingResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertSettingResource"}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Destino no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/alerts/destinations/{id}/test": {"post": {"operationId": "alertWebhookTest.test", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\n- Owner-scoped: returns 404 for both non-existent and other-owner destinations\n  to prevent resource-existence enumeration.\n- Only webhook-type destinations are allowed.\n- SSRF guard is applied inside WebhookTestService before any HTTP call.", "summary": "Test the webhook destination by sending a fixed test payload", "tags": ["AlertWebhookTest"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": ["integer", "null"]}, "ok": {"type": "boolean"}, "latency": {"type": "integer"}}, "required": ["status", "ok", "latency"]}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"message": {"type": "string"}, "status": {"type": "null"}, "ok": {"type": "boolean"}, "latency": {"type": "integer", "const": 0}}, "required": ["message", "status", "ok", "latency"]}, {"type": "object", "properties": {"message": {"type": "string", "const": "Solo los destinos webhook pueden ser probados."}}, "required": ["message"]}]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Destino no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/capi/dataset": {"post": {"operationId": "capiConnection.dataset", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nIdempotently create or recover the Meta Conversions API dataset for the\ndevice's WABA and persist it in `waba_capi_datasets`.\n\nAuthorized for the device owner only (DevicePolicy::update) — agents cannot\nprovision or manage CAPI datasets.", "summary": "POST /devices/{device}/capi/dataset", "tags": ["CapiConnection"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "`WabaCapiDatasetResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "object"}, {"$ref": "#/components/schemas/WabaCapiDatasetResource"}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/capi/test": {"get": {"operationId": "capiConnection.test", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n\"Probar conexión\": verifies the device token has the\n`whatsapp_business_manage_events` scope (`GET /debug_token`) and that the\nWABA has a dataset (`GET /{WABA_ID}/dataset`), persists the result, and\nreturns an aggregated status + a link to Meta's Events Manager.\n\nNEVER sends `POST /{DATASET_ID}/events` — Meta does not offer a\n`test_event_code` for business messaging and a test Purchase would inject\na fake sale (design.md D6).\n\nAuthorized for the device owner only (DevicePolicy::update) — agents cannot\ncheck CAPI connection status.", "summary": "GET /devices/{device}/capi/test", "tags": ["CapiConnection"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/capi/datasets": {"get": {"operationId": "capiConnection.datasets", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.\n\nRead-only status snapshot: returns the authenticated owner's already\npersisted `waba_capi_datasets` rows, one per WABA they've connected.\n\nUnlike `test()`, this endpoint NEVER calls Meta and NEVER writes — it only\nreads what a previous `dataset`/`test` call (or the Meta connection flow)\nalready persisted. Powers the connection card showing CAPI status as soon\nas it opens, without requiring the user to press \"Probar conexión\" first.\n\nNot device-scoped: not authorized via `DevicePolicy` against a single\n`Device` — it is scoped to `auth()->id()` directly, since\n`waba_capi_datasets.user_id` always holds the account owner's id (see\n`WabaCapiDataset`). Agents share the owner's account (`payment-valid` +\n`user-admin` gates on the route already require an owner-level token).", "summary": "GET /devices/capi/datasets", "tags": ["CapiConnection"], "responses": {"200": {"description": "Array of items", "content": {"application/json": {"schema": {"anyOf": [{"type": "object"}, {"type": "array", "items": {"type": "string"}}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}}, "/custom-domains": {"get": {"operationId": "customDomain.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.", "summary": "List the authenticated admin's custom domains", "tags": ["CustomDomain"], "responses": {"200": {"description": "Array of `CustomDomainResource`\n\nPaginated set of `CustomDomainResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "array", "items": {"$ref": "#/components/schemas/CustomDomainResource"}}, {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/CustomDomainResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}, "post": {"operationId": "customDomain.store", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\n`StoreCustomDomainRequest` already sanitized `hostname` and validated\nshape + uniqueness (422, no Cloudflare call) before this method runs.\nCloudflare is called next; on failure NOTHING is persisted locally\n(spec scenario \"Cloudflare API failure on creation\").\n\nRequests `HTTP` DCV by default (design.md D9 — supersedes the original\nTXT-only MVP default): `CloudflareCustomHostnameClient::create()`\ndefaults to `SslValidationMethod::Http`, so no explicit method is passed\nhere. With HTTP DCV the certificate is issued once the CNAME resolves,\nso `ssl_validation_string` (TXT-specific) is expected to stay `null` for\nnew domains — `DnsRecordBuilder` renders a single routing record (no\nTXT) accordingly. A domain can later fall back to `TXT` via\n`switchValidationMethod()`.\n\nAlso autodetects the DNS provider from the hostname's nameservers\n(design.md D14-D17, Increment 6) and persists it as `detected_provider`\n— a best-effort UX hint, never blocking creation. `null` when detection\nfails or the domain was just registered and its NS do not resolve yet\n(the \"sticky\" re-detection on `verify()` covers that case).\n\nSince Increment 9 (design.md Addendum 5 D32/D34), a Custom Hostname\nalone gets a certificate but routes no traffic — a **Worker Route**\n(`{hostname}/*` -> the `custom-hostnames` Worker) is required for the\ndomain to actually work. It is created right after the Custom Hostname,\nusing the script name from `config('services.cloudflare.worker_script')`\n(D36), and its id is persisted as `cloudflare_route_id` (internal\nbookkeeping only — never exposed via `CustomDomainResource`). If the\nCustom Hostname succeeds but the Worker Route fails, the just-created\nCustom Hostname is rolled back (best-effort delete, logged on failure)\nand NOTHING is persisted locally — a domain with a hostname but no route\nwould be a silently dead domain (SSL may go ACTIVE but no traffic ever\nreaches the resolver), which is worse than failing the request outright.", "summary": "Register a new custom domain for the authenticated admin", "tags": ["CustomDomain"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreCustomDomainRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/custom-domains/{customDomain}/verify": {"post": {"operationId": "customDomain.verify", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nScoped to the authenticated admin: a domain id belonging to another\naccount resolves to a `ModelNotFoundException`, which the app's global\nexception handler (`bootstrap/app.php`) renders as a 404 JSON response\nfor API requests — same behavior a mismatched implicit route-model\nbinding would produce.\n\nConfirmed against a live Cloudflare response: a plain `GET` never\nre-kicks validation, so a customer who just published DNS would see\n\"nothing happen\" until Cloudflare's own background re-check eventually\nran. This method issues a `PATCH` (via `patchSsl()`) with the domain's\nCURRENT `ssl_validation_method` — a re-trigger, not a method switch —\nwhich Cloudflare confirmed re-kicks the DCV check instantly. The\nresulting state is still asynchronous: it may not reflect `ACTIVE` in\nthis same response, but is captured here or on a subsequent\nverify/sweep.\n\nIf Cloudflare is unreachable or rejects the request, the domain's\npersisted `status` is left untouched and a 502 is returned (the\n`CloudflareApiException` thrown by the client already logs the raw\nerror before this method ever sees it).\n\n`ssl_validation_string` is TXT-specific (design.md D9): it is only\nre-captured from the response when the domain's method is `TXT`. For\nthe `HTTP` default it stays `null`, matching `DnsRecordBuilder`'s\nmethod-aware branching (design.md D12).\n\nAlso re-detects the DNS provider hint (design.md D15, Increment 6)\nfollowing a STICKY policy: `detected_provider` is only overwritten\nwhen the detector returns a non-null value. A transient `null` (e.g. a\nflaky lookup) never clobbers a previously good hint. This covers the\ncommon case where NS did not resolve yet at `store()` time (freshly\nregistered domain) but do resolve by the time the admin verifies.", "summary": "Actively re-trigger Cloudflare Domain Control Validation for a domain\nand persist the resolved canonical status (\"verify now\", design.md\nD4/D6/D10)", "tags": ["CustomDomain"], "parameters": [{"name": "customDomain", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"200": {"description": "`CustomDomainResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "object"}, {"$ref": "#/components/schemas/CustomDomainResource"}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/custom-domains/{customDomain}/switch-validation": {"post": {"operationId": "customDomain.switchValidationMethod", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nThis is the escape hatch for domains whose CNAME does not yet route to\nCloudflare: HTTP DCV cannot complete without traffic reaching the\norigin, but TXT DCV validates purely from a DNS record. Scoped to the\nauthenticated admin via `findOwned()` (404 if not owned). Calls\n`patchSsl()` with the NEW method (a \"switch\", as opposed to `verify()`'s\nsame-method \"re-trigger\") and persists it. When switching TO `TXT`, the\n`ssl_validation_string` is captured best-effort from the response —\nCloudflare frequently has not yet populated `ssl.validation_records` at\nPATCH time (confirmed against a live Cloudflare response: it is filled\nin asynchronously, a few seconds later). When the PATCH response comes\nback empty, a best-effort follow-up `get()` is issued immediately to try\nto capture it right away — this covers the common case where the value\nis already available a moment later, so the customer sees the TXT\nrecord without having to wait for a subsequent `verify()`/sweep. The\nfollow-up is best-effort: if it also comes back empty, or Cloudflare\nfails/is unreachable for it, the switch still succeeds and a later\n`verify()`/sweep captures the value once available (bug fix: the TXT\n`dns_record` was never shown to the user when the PATCH raced\nCloudflare's population of `ssl.validation_records`).", "summary": "Switch a domain's SSL Domain Control Validation (DCV) method between\n`HTTP` (default) and `TXT` (fallback, design.md D11)", "tags": ["CustomDomain"], "parameters": [{"name": "customDomain", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SwitchValidationMethodRequest"}}}}, "responses": {"200": {"description": "`CustomDomainResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "object"}, {"$ref": "#/components/schemas/CustomDomainResource"}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/custom-domains/{customDomain}": {"delete": {"operationId": "customDomain.destroy", "description": "**Escritura** · requiere el permiso `accounts:operate`.\n\n> ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso.\n\nBy design the local row is removed even when the Cloudflare delete\ncall fails (`communities` must stop treating the hostname as\nresolvable immediately) — the failure is logged with the\n`cloudflare_id` for later manual cleanup of the orphaned Custom\nHostname (design.md Risks: \"Cloudflare falla el DELETE pero borramos\n     * local\"). `CloudflareCustomHostnameClient::delete()` already logs the\nraw Cloudflare error before throwing; this method logs the follow-up\ndecision to proceed with the local deletion anyway.\n\nSince Increment 9 (design.md Addendum 5 D35), the Worker Route created\nin `store()` is also torn down here, best-effort and INDEPENDENTLY from\nthe Custom Hostname delete (each in its own try/catch — one failing\nnever prevents attempting the other). Guarded by `cloudflare_route_id\n!== null` for any row without one (e.g. a theoretical pre-Increment-9\nrow). The local row is hard-deleted UNCONDITIONALLY, whether or not\neither Cloudflare call succeeded — orphaned Cloudflare resource ids are\nlogged for later manual cleanup.", "summary": "Unregister a custom domain: delete it from Cloudflare, then hard-delete\nthe local row (design.md D6)", "tags": ["CustomDomain"], "parameters": [{"name": "customDomain", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "No content"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:operate", "x-fc-destructive": true}}, "/devices": {"get": {"operationId": "device.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.", "tags": ["Device"], "responses": {"200": {"description": "Array of `DeviceResource`\n\nPaginated set of `DeviceResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "array", "items": {"$ref": "#/components/schemas/DeviceResource"}}, {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}}, "/devices/{device}/me": {"get": {"operationId": "device.me", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"500": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"anyOf": [{"type": "string"}, {"type": "string", "enum": ["An error has occurred"]}]}}, "required": ["message"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "El número no está conectado a WhatsApp. Conectalo desde el panel de Funnelchat y volvé a intentar."}, "error": {"type": "string", "const": "device_not_connected"}}, "required": ["message", "error"]}}}}, "200": {"description": "", "content": {"application/json": {"schema": {"type": "null"}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/check-phone": {"get": {"operationId": "device.checkPhone", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "phone", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": {"500": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"anyOf": [{"type": "string"}, {"type": "string", "enum": ["An error has occurred"]}]}}, "required": ["message"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "El número no está conectado a WhatsApp. Conectalo desde el panel de Funnelchat y volvé a intentar."}, "error": {"type": "string", "const": "device_not_connected"}}, "required": ["message", "error"]}}}}, "200": {"description": "", "content": {"application/json": {"schema": {"type": "null"}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "422": {"$ref": "#/components/responses/ValidationException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}": {"get": {"operationId": "device.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "`DeviceResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeviceResource"}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "put": {"operationId": "device.update", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"alias": {"type": ["string", "null"]}, "color": {"type": ["string", "null"]}, "image": {"type": ["string", "null"], "format": "uri"}, "number": {"type": ["string", "null"]}, "first_conecction_at": {"type": ["string", "null"], "format": "date-time"}, "is_business": {"type": "boolean"}, "phone_number_id": {"type": "string"}, "waba_id": {"type": "string"}, "code": {"type": "string"}, "whatsApp_business_app": {"type": "boolean"}, "capi_consent": {"type": "boolean", "description": "Meta Conversions API (CAPI) opt-in consent — capi-meta-connection Inc 3\n(design.md D3). Optional: only meaningful on the Meta branch, and even\nthere it is a no-op unless the `capi` feature flag is enabled for the\naccount (defense in depth, see updateMetaDevice below)."}}}}}}, "responses": {"400": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string", "const": "Unsupported provider"}}, "required": ["error"]}}}}, "200": {"description": "`DeviceResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeviceResource"}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "array", "items": {}}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}, "delete": {"operationId": "device.destroy", "description": "**Escritura** · requiere el permiso `accounts:operate`.\n\n> ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso.", "summary": "`DELETE /api/devices/{device}` (whatsapp-preverified-number-assignment D24; spec\n`device-number-release`, \"Deleting a device is an explicit release of that row\"). Same\nauthorization and routes as before (owner; API keys with `operate`; `can:payment-valid`).\nBoth modes. Inside the account section: 409 `operation_in_progress` while a Meta signup\nholds a live number reservation on the row; otherwise a `deleted` audit event (actor\n`user:{id}`, `source: api_key` for an API key; it survives the deletion) and the physical\ndeletion. After the commit: best-effort provider teardown (QR `unsubscribe()`, Meta webhook\nunsubscription, never `/deregister`) and 1:1 back on for the QR sibling. A connected row may\nstill be deleted. A teardown failure is logged and does not fail the request (200)", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": ""}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "array", "items": {}}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:operate", "x-fc-destructive": true}}, "/devices/{device}/reset": {"put": {"operationId": "device.reset", "description": "**Escritura** · requiere el permiso `accounts:operate`.\n\n> ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso.", "summary": "\"Borrar datos\" (whatsapp-preverified-number-assignment D60; spec `device-connection-actions`):\nerases every column of the number on this connection, QR and Meta, both modes, so the card\nconnects a DIFFERENT number (normal free-card rules: class A when the pool is full). Kept:\nalias (and color; Meta also image and `is_coexistence`), `active`, `is_additional`,\n`provider`. See `EraseDeviceNumberData`. 409 `device_retired` / `operation_in_progress`\n(nothing erased). 200 `{data: DeviceResource, numbers_capacity}`. The front asks for a red\nconfirmation before calling it", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {}}, "numbers_capacity": {"type": ["string", "null"]}}, "required": ["data", "numbers_capacity"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "array", "items": {}}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:operate", "x-fc-destructive": true}}, "/devices/{device}/update-webhook-received": {"put": {"operationId": "device.updateWebhookReceived", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/update-webhook-received-and-delivery": {"put": {"operationId": "device.updateWebhookReceivedAndDelivery", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/clean": {"post": {"operationId": "device.clean", "description": "**Escritura** · requiere el permiso `accounts:operate`.\n\n> ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso.", "summary": "\"Desconectar\" (whatsapp-preverified-number-assignment D60; spec `device-connection-actions`):\nnever erases the number information and never frees the place, QR and Meta, both modes.\n - QR: provider logout of the WhatsApp session; the instance (`uid`, `token`) stays so the\n   user scans again; alias, color, image, app name, identity and history are kept.\n - Meta: the QR sibling's one-to-one is turned back on FIRST (synchronously, while the row\n   still has its number, D60.7); then only the access token is cleared (no provider call,\n   `status()` answers `pending_login`, D60.10); `uid` (phone_number_id),\n   `waba_id`, identity and `is_coexistence` are kept, so the same number reconnects with\n   Embedded Signup on the same row (also at or over the pool: D59 S1 / D20 rule 4).\n`number` goes NULL as before (the card shows `number_key`). 409 `device_retired` on a\nretired row; 409 `operation_in_progress` while a Meta signup holds a number reservation.\n200 `{data: DeviceResource, numbers_capacity}` (`numbers_capacity` omitted when it cannot\nbe computed, D59.12)", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {}}, "numbers_capacity": {"type": ["string", "null"]}}, "required": ["data", "numbers_capacity"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "array", "items": {}}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:operate", "x-fc-destructive": true}}, "/devices/{device}/chats": {"get": {"operationId": "device.chats", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `pageSize` · orden fijo: el que devuelve el proveedor · no acepta parámetros de orden.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "phone", "in": "query", "schema": {"type": "string"}}, {"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "pageSize", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "type", "in": "query", "schema": {"type": "integer", "enum": ["1", "2"]}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"phones": {"type": "array", "items": {}}, "total": {"type": "integer"}, "page": {"anyOf": [{"type": "string"}, {"type": "integer", "enum": [1]}]}, "per_page": {"anyOf": [{"type": "string"}, {"type": "integer", "enum": [25]}]}, "last_page": {"type": "number"}}, "required": ["phones", "total", "page", "per_page", "last_page"]}, {"type": "null"}]}}}}, "500": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"anyOf": [{"type": "string"}, {"type": "string", "enum": ["An error has occurred"]}]}}, "required": ["message"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "El número no está conectado a WhatsApp. Conectalo desde el panel de Funnelchat y volvé a intentar."}, "error": {"type": "string", "const": "device_not_connected"}}, "required": ["message", "error"]}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "422": {"$ref": "#/components/responses/ValidationException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "pageSize"], "order": "el que devuelve el proveedor", "sortable": []}}}, "/devices/{device}/open-conversations": {"get": {"operationId": "device.openConversations", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Con API key** · Con una clave de API, `phones` admite como máximo 100 teléfonos por llamada. Más responde `422`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "phones[]", "in": "query", "required": true, "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": ["object", "array"]}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/message-queue": {"get": {"operationId": "device.messageQueue", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `pageSize` · orden fijo: el que devuelve el proveedor · no acepta parámetros de orden.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "page", "in": "query", "schema": {"type": "integer"}}, {"name": "pageSize", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": ["array", "object"]}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "pageSize"], "order": "el que devuelve el proveedor", "sortable": []}}, "delete": {"operationId": "device.deleteMessageQueue", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "messageUids[]", "in": "query", "required": true, "schema": {"type": "array", "items": {"type": "string"}, "minItems": 1}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": ["object", "array"]}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/message-queue/count": {"get": {"operationId": "device.getQueueCount", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": ["array", "object"]}}}}, "502": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Upstream gateway error"}}, "required": ["message"]}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/message-queue/all": {"delete": {"operationId": "device.clearQueue", "description": "**Escritura** · requiere el permiso `accounts:operate`.\n\n> ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": ["array", "object"]}}}}, "204": {"description": "No content"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:operate", "x-fc-destructive": true}}, "/devices/{device}/status": {"get": {"operationId": "device.status", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/extension-token": {"post": {"operationId": "device.extensionToken", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nOnly available for ZApi and ZApiLite providers. Returns a one-time code with\nan expiry timestamp. The frontend uses this code to authenticate the extension\nwithout ever receiving the raw instance credentials.", "summary": "Request a short-lived extension token for transferring a WhatsApp Web session\nto this device instance via the Z-API Conector Chrome extension", "tags": ["Device"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "`ExtensionTokenResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExtensionTokenResource"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/connection-history": {"get": {"operationId": "deviceConnectionEvent.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: created_at descendente · no acepta parámetros de orden.", "tags": ["DeviceConnectionEvent"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "orderBy", "in": "query", "schema": {"type": "string"}}, {"name": "orderByMethod", "in": "query", "schema": {"type": "string"}}, {"name": "perPage", "in": "query", "schema": {"type": "integer"}}], "responses": {"200": {"description": "Paginated set of `DeviceConnectionEventResource`", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceConnectionEventResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "created_at descendente", "sortable": []}}}, "/devices/{device}/disconnect-survey": {"get": {"operationId": "deviceDisconnectSurvey.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["DeviceDisconnectSurvey"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "`DisconnectSurveyResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DisconnectSurveyResource"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "post": {"operationId": "deviceDisconnectSurvey.store", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["DeviceDisconnectSurvey"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreDisconnectReasonRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/logs": {"get": {"operationId": "deviceFleetLogs.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `limit` · orden fijo: más reciente primero · no acepta parámetros de orden.", "summary": "List `device_logs` rows across the account within a bounded window,\nnewest-first, keyset-paginated. Returns a `summary` only on the first\npage (no `cursor`)", "tags": ["DeviceFleetLogs"], "parameters": [{"name": "range", "in": "query", "schema": {"type": ["string", "null"], "enum": ["1h", "6h", "24h", "7d", "30d", null]}}, {"name": "from", "in": "query", "schema": {"type": ["string", "null"], "format": "date-time"}}, {"name": "to", "in": "query", "schema": {"type": ["string", "null"], "format": "date-time"}}, {"name": "only_errors", "in": "query", "schema": {"type": ["boolean", "null"]}}, {"name": "exclude_sends", "in": "query", "schema": {"type": ["boolean", "null"]}}, {"name": "methods[]", "in": "query", "schema": {"type": "array", "items": {"type": "string", "maxLength": 64}}}, {"name": "cursor", "in": "query", "schema": {"type": ["string", "null"]}}, {"name": "limit", "in": "query", "schema": {"type": ["integer", "null"], "minimum": 1, "maximum": 500}}, {"name": "per_device_cap", "in": "query", "schema": {"type": ["integer", "null"], "minimum": 1, "maximum": 150}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceFleetLogResource"}}, "devices": {"type": "array", "items": {"type": "object", "properties": {"device_id": {"type": "integer"}, "label": {"type": "string"}, "connected": {"type": "boolean"}, "activity_count": {"type": "integer"}, "truncated": {"type": ["string", "boolean"]}}, "required": ["device_id", "label", "connected", "activity_count", "truncated"]}}, "next_cursor": {"type": "string"}, "has_more": {"type": "string"}, "window": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}}, "required": ["from", "to"]}, "summary": {"type": "string"}}, "required": ["data", "devices", "next_cursor", "has_more", "window", "summary"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "limit"], "order": "más reciente primero", "sortable": []}}}, "/devices/{device}/health": {"get": {"operationId": "deviceHealth.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "summary": "`GET /devices/{device}/health` — serves `currentSnapshot()`\nnormalized through `DeviceHealthDetailResource`. NEVER calls Meta", "tags": ["DeviceHealth"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "`DeviceHealthDetailResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DeviceHealthDetailResource"}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string", "const": "device_not_meta"}, "message": {"type": "string", "const": "Este dispositivo no usa la API de WhatsApp Cloud de Meta."}}, "required": ["error", "message"]}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/health/refresh": {"post": {"operationId": "deviceHealth.refresh", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\n`refresh_outcome` (security-auditor finding — Increment 4 plan\nreview, \"the endpoint must tell the truth about what happened\"):\n`refreshAndPersist()` can return the pre-existing snapshot for THREE\ndifferent reasons — it genuinely refreshed, it bounced off the 60s\ncooldown, or it couldn't acquire the per-device lock (another refresh\n— the scheduled command, another tab, another agent — is already in\nflight for this exact device) — and from the HTTP response alone\nthose three are indistinguishable. A 200 with stale data and no\nsignal after the user clicks \"Actualizar ahora\" is a silent lie.\n\nResolution: compare `last_fetched_at` BEFORE and AFTER the call.\n`refreshAndPersist()` ALWAYS advances `last_fetched_at` when it\ngenuinely attempts Meta — success or failure alike (design.md D7:\nit's attempt metadata, not a health field) — and NEVER advances it on\neither bounce. So:\n  - unchanged (or still null) → `'not_refreshed_yet'` — covers BOTH\n    the cooldown bounce and the lock-contention bounce; the frontend\n    doesn't need to tell them apart to render \"esperá un momento\",\n    and collapsing them avoids inventing a distinction the service's\n    own public contract (`refreshAndPersist(): DeviceHealthStatus`,\n    Increment 1, already covered by 40+ tests) doesn't expose without\n    a breaking change.\n  - advanced → `'refreshed'` — a live call was actually dispatched,\n    regardless of whether Meta answered with success or an error\n    (that distinction is already visible via `health.state`/`stale`).", "summary": "`POST /devices/{device}/health/refresh` — triggers\n`MetaHealthStatusService::refreshAndPersist()` (at most ONE live Meta\ncall, subject to D7's 60s cooldown and per-device `Cache::lock`) and\nreturns the SAME shape as `show()`, plus `refresh_outcome`", "tags": ["DeviceHealth"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"health": {"type": "string"}, "identity": {"type": "string"}, "entities": {"type": "string"}, "throughput_level": {"type": "string", "description": "Detail-only (meta-number-health-status, Increment 6 gap-closing\nfollow-up) — deliberately NOT part of `health`, which the\nlisting shares verbatim; see\nDeviceHealthSummaryBuilder::buildThroughputLevel()."}, "history": {"type": "array", "items": {"type": "object", "properties": {"field": {"type": "string"}, "previous_value": {"type": "string"}, "new_value": {"type": "string"}, "detail": {"type": ["string", "null"]}, "source": {"type": "string"}, "occurred_at": {"type": "string"}}, "required": ["field", "previous_value", "new_value", "detail", "source", "occurred_at"]}}, "meta_billing": {"type": ["object", "null"], "properties": {"waba_id": {"type": ["string", "null"]}, "business_id": {"type": ["string", "null"]}}, "required": ["waba_id", "business_id"]}, "refresh_outcome": {"type": "string", "enum": ["refreshed", "not_refreshed_yet"]}}, "required": ["health", "identity", "entities", "throughput_level", "history", "meta_billing", "refresh_outcome"]}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string", "const": "device_not_meta"}, "message": {"type": "string", "const": "Este dispositivo no usa la API de WhatsApp Cloud de Meta."}}, "required": ["error", "message"]}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/logs": {"get": {"operationId": "deviceLogs.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `limit` · orden fijo: más reciente primero · no acepta parámetros de orden.", "summary": "List `device_logs` rows for the device within a bounded window,\nnewest-first, keyset-paginated. Returns a `summary` only on the first\npage (no `cursor`)", "tags": ["DeviceLogs"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}, {"name": "range", "in": "query", "schema": {"type": ["string", "null"], "enum": ["1h", "6h", "24h", "7d", "30d", null]}}, {"name": "from", "in": "query", "schema": {"type": ["string", "null"], "format": "date-time"}}, {"name": "to", "in": "query", "schema": {"type": ["string", "null"], "format": "date-time"}}, {"name": "only_errors", "in": "query", "schema": {"type": ["boolean", "null"]}}, {"name": "exclude_sends", "in": "query", "schema": {"type": ["boolean", "null"]}}, {"name": "methods[]", "in": "query", "schema": {"type": "array", "items": {"type": "string", "maxLength": 64}}}, {"name": "cursor", "in": "query", "schema": {"type": ["string", "null"]}}, {"name": "limit", "in": "query", "schema": {"type": ["integer", "null"], "minimum": 1, "maximum": 500}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceLogResource"}}, "next_cursor": {"type": "string"}, "has_more": {"type": "string"}, "window": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}}, "required": ["from", "to"]}, "summary": {"anyOf": [{"type": "object", "properties": {"total": {"type": "integer"}, "errors": {"type": "integer"}}, "required": ["total", "errors"]}, {"type": "object", "properties": {"total": {"type": "integer", "const": 0}, "errors": {"type": "integer", "const": 0}}, "required": ["total", "errors"]}]}}, "required": ["data", "next_cursor", "has_more", "window", "summary"]}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "limit"], "order": "más reciente primero", "sortable": []}}}, "/devices/{device}/logs/health": {"get": {"operationId": "deviceLogs.health", "description": "**Lectura** · requiere el permiso `accounts:read`.", "summary": "Return the device's health signal: the last 5 non-send requests plus a\nverdict (`healthy`/`review`/`attention`)", "tags": ["DeviceLogs"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"verdict": {"type": "string"}, "fail_count": {"type": "string"}, "sample_size": {"type": "string"}, "requests": {"type": "array", "items": {"$ref": "#/components/schemas/DeviceLogResource"}}}, "required": ["verdict", "fail_count", "sample_size", "requests"]}}}}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/devices/{device}/username": {"get": {"operationId": "deviceUsername.show", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\nFetch the current reserved username from Meta and return it along with the locally stored value.", "summary": "GET /devices/{device}/username", "tags": ["DeviceUsername"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"username": {}}, "required": ["username"]}, {"type": "string"}]}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"error": {"type": "string", "const": "El dispositivo no tiene credenciales Meta configuradas."}}, "required": ["error"]}, {"type": "object", "properties": {"error": {"type": "string", "const": "Solo disponible para dispositivos WhatsApp Cloud API."}}, "required": ["error"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "post": {"operationId": "deviceUsername.store", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nReserve a username via Meta API and persist it locally.", "summary": "POST /devices/{device}/username", "tags": ["DeviceUsername"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"username": {"type": "string", "pattern": "^[a-z0-9._]+$", "minLength": 3, "maxLength": 35}}, "required": ["username"]}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"username": {"type": "string"}, "message": {"type": "string", "const": "Nombre de usuario reservado exitosamente."}}, "required": ["username", "message"]}, {"type": "string"}]}}}}, "422": {"$ref": "#/components/responses/ValidationException"}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/devices/{device}/username/suggestions": {"get": {"operationId": "deviceUsername.suggestions", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\nFetch username suggestions from Meta API.", "summary": "GET /devices/{device}/username/suggestions", "tags": ["DeviceUsername"], "parameters": [{"name": "device", "in": "path", "required": true, "description": "The device ID", "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"suggestions": {}}, "required": ["suggestions"]}, {"type": "string"}]}}}}, "422": {"description": "", "content": {"application/json": {"schema": {"anyOf": [{"type": "object", "properties": {"error": {"type": "string", "const": "El dispositivo no tiene credenciales Meta configuradas."}}, "required": ["error"]}, {"type": "object", "properties": {"error": {"type": "string", "const": "Solo disponible para dispositivos WhatsApp Cloud API."}}, "required": ["error"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "404": {"$ref": "#/components/responses/ModelNotFoundException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/webhooks/endpoints/{id}/deliveries": {"get": {"operationId": "webhookDelivery.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: attempted_at descendente, después id descendente · no acepta parámetros de orden.", "tags": ["WebhookDelivery"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Paginated set of `WebhookDeliveryResource`", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/WebhookDeliveryResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Endpoint de webhook no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "per_page"], "order": "attempted_at descendente, después id descendente", "sortable": []}}}, "/webhooks/endpoints": {"get": {"operationId": "webhookEndpoint.index", "description": "**Lectura** · requiere el permiso `accounts:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.", "summary": "Los endpoints de la cuenta, con sus suscripciones", "tags": ["WebhookEndpoint"], "responses": {"200": {"description": "Array of `WebhookEndpointResource`\n\nPaginated set of `WebhookEndpointResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "array", "items": {"$ref": "#/components/schemas/WebhookEndpointResource"}}, {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/WebhookEndpointResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}, "post": {"operationId": "webhookEndpoint.store", "description": "**Escritura** · requiere el permiso `accounts:write`.", "summary": "Registra un endpoint y devuelve su secreto de firma — la única vez", "tags": ["WebhookEndpoint"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreWebhookEndpointRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/webhooks/endpoints/{id}": {"get": {"operationId": "webhookEndpoint.show", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["WebhookEndpoint"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"200": {"description": "`WebhookEndpointResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookEndpointResource"}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Endpoint de webhook no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}, "patch": {"operationId": "webhookEndpoint.update", "description": "**Escritura** · requiere el permiso `accounts:write`.\n\nEl `SsrfGuard` corre TAMBIÉN acá y no sólo en el alta: si corriera sólo en\nel alta, editar sería la puerta de atrás para dejar persistida una URL que\napunta a la red interna.", "summary": "Edita la URL, la descripción o el estado", "tags": ["WebhookEndpoint"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWebhookEndpointRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}, "delete": {"operationId": "webhookEndpoint.destroy", "description": "**Escritura** · requiere el permiso `accounts:write`.", "summary": "Da de baja el endpoint. Las suscripciones y el historial de entregas caen\ncon él por `ON DELETE CASCADE`", "tags": ["WebhookEndpoint"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Endpoint de webhook no encontrado."}}, "required": ["message"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/webhooks/endpoints/{id}/events": {"put": {"operationId": "webhookEndpointEvent.update", "description": "**Escritura** · requiere el permiso `accounts:write`.", "tags": ["WebhookEndpointEvent"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWebhookEndpointEventsRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "accounts:write", "x-fc-destructive": false}}, "/webhooks/events": {"get": {"operationId": "webhookEventCatalog.index", "description": "**Lectura** · requiere el permiso `accounts:read`.", "tags": ["WebhookEventCatalog"], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {}}, "meta": {"type": "object", "properties": {"total": {"type": "integer"}, "active": {"type": "integer"}, "envelope_version": {"type": "string", "description": "La versión del formato estándar, no la de la API HTTP.", "const": "v1"}, "signature_header": {"type": "string", "const": "X-Funnelchat-Signature"}, "signature_format": {"type": "string", "const": "t=<unix>,v1=<hex hmac-sha256 de \"<t>.<cuerpo crudo>\" con el secreto del endpoint>"}}, "required": ["total", "active", "envelope_version", "signature_header", "signature_format"]}}, "required": ["data", "meta"]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}, "/webhooks": {"get": {"operationId": "webhookSubscription.index", "description": "**Lectura** · requiere el permiso `webhooks:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden.\n\nCon una clave de API, solo las que la clave puede leer: se omiten las que\ntienen eventos de un área cuyo permiso de lectura (`accounts:read`,\n`conversations:read`, `communities:read`) le falta.", "summary": "Lista las suscripciones de webhook de la cuenta, por id ascendente", "tags": ["WebhookSubscription"], "responses": {"200": {"description": "Array of `WebhookSubscriptionResource`\n\nPaginated set of `WebhookSubscriptionResource`", "content": {"application/json": {"schema": {"anyOf": [{"type": "array", "items": {"$ref": "#/components/schemas/WebhookSubscriptionResource"}}, {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/WebhookSubscriptionResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}]}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "webhooks:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage"], "order": "id ascendente", "sortable": []}}, "post": {"operationId": "webhookSubscription.store", "description": "**Escritura** · requiere el permiso `webhooks:write`.\n\nCon una clave de API, cada tipo de `events` exige además el permiso de\nlectura de su área (`accounts:read`, `conversations:read`,\n`communities:read`); si falta, 422.", "summary": "Crea una suscripción y devuelve su secreto — la única vez", "tags": ["WebhookSubscription"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreWebhookSubscriptionRequest"}}}}, "responses": {"403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}, "201": {"description": "`WebhookSubscriptionCreatedResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookSubscriptionCreatedResource"}}}}}, "x-fc-access": "write", "x-fc-permission": "webhooks:write", "x-fc-destructive": false}}, "/webhooks/{id}": {"get": {"operationId": "webhookSubscription.show", "description": "**Lectura** · requiere el permiso `webhooks:read`.\n\nCon una clave de API, la clave necesita además el permiso de lectura del\nárea de cada tipo de evento de la suscripción; si falta alguno, 403\n`permission_denied`.", "summary": "Devuelve una suscripción de webhook de la cuenta, sin su secreto", "tags": ["WebhookSubscription"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "`WebhookSubscriptionResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookSubscriptionResource"}}}}, "403": {"description": "La clave de API no tiene el permiso de lectura de un área de los eventos de la suscripción.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}, "required": ["error", "message"]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Suscripción de webhook no encontrada."}}, "required": ["message"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "webhooks:read", "x-fc-destructive": false}, "put": {"operationId": "webhookSubscription.update", "description": "**Escritura** · requiere el permiso `webhooks:write`.\n\nCon una clave de API, cada tipo de `events` exige además el permiso de\nlectura de su área (`accounts:read`, `conversations:read`,\n`communities:read`); si falta, 422.\n\nCon una clave de API, la clave necesita además el permiso de lectura del\nárea de cada tipo de evento que la suscripción tiene ahora; si falta alguno,\n403 `permission_denied` y no se cambia nada. Un cuerpo inválido responde 422\nantes de mirar la suscripción.\n\nSi una clave de API cambia `url`, la suscripción queda atada a esa clave:\ncuando se revoque, pasa a `disabled` / `key_revoked`.", "summary": "Reemplaza `url`, `events`, `description` y `filters`; `status` opcional", "tags": ["WebhookSubscription"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWebhookSubscriptionRequest"}}}}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object"}}}}, "403": {"description": "La clave de API no tiene el permiso de lectura de un área de los eventos de la suscripción.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}, "required": ["error", "message"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}, "422": {"$ref": "#/components/responses/ValidationException"}}, "x-fc-access": "write", "x-fc-permission": "webhooks:write", "x-fc-destructive": false}, "delete": {"operationId": "webhookSubscription.destroy", "description": "**Escritura** · requiere el permiso `webhooks:write`.\n\nCon una clave de API, la clave necesita además el permiso de lectura del\nárea de cada tipo de evento de la suscripción; si falta alguno, 403\n`permission_denied` y no se borra.", "summary": "Da de baja la suscripción. Sus eventos y su historial caen por cascada", "tags": ["WebhookSubscription"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "No content", "content": {"application/json": {"schema": {"type": "null"}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Suscripción de webhook no encontrada."}}, "required": ["message"]}}}}, "409": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Esta suscripción se está activando; esperá a que termine."}}, "required": ["message"]}}}}, "403": {"description": "La clave de API no tiene el permiso de lectura de un área de los eventos de la suscripción.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}, "required": ["error", "message"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "webhooks:write", "x-fc-destructive": false}}, "/webhooks/{id}/deliveries": {"get": {"operationId": "webhookSubscriptionDelivery.index", "description": "**Lectura** · requiere el permiso `webhooks:read`.\n\n**Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: attempted_at descendente, después id descendente · no acepta parámetros de orden.\n\nCon una clave de API, la clave necesita además el permiso de lectura del\nárea de cada tipo de evento de la suscripción; si falta alguno, 403\n`permission_denied`.\n\nCon una clave de API, solo las entregas de tipos cuya área la clave puede\nleer; las de prueba, `webhook.test`, se muestran siempre.", "summary": "Lista los intentos de entrega de una suscripción, el más nuevo primero", "tags": ["WebhookSubscriptionDelivery"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Paginated set of `WebhookSubscriptionDeliveryResource`", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"$ref": "#/components/schemas/WebhookSubscriptionDeliveryResource"}}, "links": {"type": "object", "properties": {"first": {"type": ["string", "null"]}, "last": {"type": ["string", "null"]}, "prev": {"type": ["string", "null"]}, "next": {"type": ["string", "null"]}}, "required": ["first", "last", "prev", "next"]}, "meta": {"type": "object", "properties": {"current_page": {"type": "integer", "minimum": 1}, "from": {"type": ["integer", "null"], "minimum": 1}, "last_page": {"type": "integer", "minimum": 1}, "links": {"type": "array", "description": "Generated paginator links.", "items": {"type": "object", "properties": {"url": {"type": ["string", "null"]}, "label": {"type": "string"}, "active": {"type": "boolean"}}, "required": ["url", "label", "active"]}}, "path": {"type": ["string", "null"], "description": "Base path for paginator generated URLs."}, "per_page": {"type": "integer", "description": "Number of items shown per page.", "minimum": 0}, "to": {"type": ["integer", "null"], "description": "Number of the last item in the slice.", "minimum": 1}, "total": {"type": "integer", "description": "Total number of items being paginated.", "minimum": 0}}, "required": ["current_page", "from", "last_page", "links", "path", "per_page", "to", "total"]}}, "required": ["data", "links", "meta"]}}}}, "403": {"description": "La clave de API no tiene el permiso de lectura de un área de los eventos de la suscripción.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}, "required": ["error", "message"]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Suscripción de webhook no encontrada."}}, "required": ["message"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "webhooks:read", "x-fc-destructive": false, "x-fc-listing": {"per_page_default": 25, "per_page_max": 100, "size_params": ["perPage", "per_page"], "order": "attempted_at descendente, después id descendente", "sortable": []}}}, "/webhooks/{id}/test": {"post": {"operationId": "webhookSubscriptionTest.test", "description": "**Escritura** · requiere el permiso `webhooks:write`.\n\nSolo a suscripciones `active` o `paused`; una `disabled` responde 422 sin enviar nada.\n\nCon una clave de API, la clave necesita además el permiso de lectura del área\nde cada tipo de evento de la suscripción; si falta alguno, 403\n`permission_denied`, antes que el 422 de una `disabled`.", "summary": "Envía una entrega de prueba firmada a la URL de la suscripción y devuelve el resultado", "tags": ["WebhookSubscriptionTest"], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"succeeded": {"type": "boolean"}, "response_status": {"type": ["integer", "null"]}, "error": {"type": ["string", "null"], "enum": ["non_2xx", "internal_error", "timeout", "connection_failed", "tls_error", "destination_not_allowed", "secret_unreadable", null]}, "duration_ms": {"type": ["integer", "null"]}, "delivery_id": {"type": "integer"}}, "required": ["succeeded", "response_status", "error", "duration_ms", "delivery_id"]}}}}, "422": {"description": "La suscripción está deshabilitada: no se envía nada.", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"]}}}}, "403": {"description": "La clave de API no tiene el permiso de lectura de un área de los eventos de la suscripción.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}, "required": ["error", "message"]}}}}, "404": {"description": "", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "const": "Suscripción de webhook no encontrada."}}, "required": ["message"]}}}}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "write", "x-fc-permission": "webhooks:write", "x-fc-destructive": false}}, "/me/whatsapp-costs": {"get": {"operationId": "whatsappCost.index", "description": "**Lectura** · requiere el permiso `accounts:read`.", "summary": "Returns the full `WhatsappCostSummaryResource` for the requested\n(or current, when omitted) calendar month — `?month=YYYY-MM`", "tags": ["WhatsappCost"], "parameters": [{"name": "month", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"description": "`WhatsappCostSummaryResource`", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WhatsappCostSummaryResource"}}}}, "403": {"$ref": "#/components/responses/AuthorizationException"}, "401": {"$ref": "#/components/responses/AuthenticationException"}}, "x-fc-access": "read", "x-fc-permission": "accounts:read", "x-fc-destructive": false}}}, "components": {"securitySchemes": {"http": {"type": "http", "scheme": "bearer"}}, "schemas": {"AlertAction": {"type": "string", "enum": ["disconnected", "usage_80", "expiring", "usage_100", "upcoming_charge", "payment_failed", "plan_blocked", "reactivated", "trial_ending", "cancellation_confirmed", "dormant_ending", "blocked", "no_groups_available", "daily_report", "action_failed", "message_failed", "full", "invalid_link", "low_balance", "topup_succeeded", "topup_failed"], "title": "AlertAction"}, "AlertCampaignResource": {"type": "object", "properties": {"id": {"type": "string"}, "uuid": {"type": "string"}, "name": {"type": "string"}, "is_active": {"type": "boolean"}, "groups_count": {"type": "integer"}, "type": {"type": ["integer", "null"]}}, "required": ["id", "uuid", "name", "is_active", "groups_count", "type"], "title": "AlertCampaignResource"}, "AlertCatalogResource": {"type": "object", "properties": {"module": {"type": "string"}, "action": {"type": "string"}, "category": {"type": "string"}, "is_critical": {"type": "string"}, "label": {"type": "string"}, "tooltip": {"type": "string"}}, "required": ["module", "action", "category", "is_critical", "label", "tooltip"], "title": "AlertCatalogResource"}, "AlertDailyReportResource": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "send_time": {"type": ["string", "null"]}, "all_campaigns": {"type": "boolean"}, "campaign_ids": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 0, "maxItems": 0}]}}, "required": ["enabled", "send_time", "all_campaigns", "campaign_ids"], "title": "AlertDailyReportResource"}, "AlertDestinationResource": {"type": "object", "properties": {"id": {"type": "integer"}, "type": {"type": "string"}, "destination": {"type": "string"}, "name": {"type": ["string", "null"]}, "verified": {"type": "boolean"}, "has_secret": {"type": "string"}, "created_at": {"type": ["string", "null"]}, "settings": {"type": "array", "items": {"$ref": "#/components/schemas/AlertSettingResource"}}}, "required": ["id", "type", "destination", "name", "verified", "has_secret", "created_at"], "title": "AlertDestinationResource"}, "AlertDestinationType": {"type": "string", "enum": ["phone", "webhook"], "title": "AlertDestinationType"}, "AlertModule": {"type": "string", "enum": ["device", "mac", "billing", "subscription", "account", "campaign", "group", "ai_quota"], "title": "AlertModule"}, "AlertQuietHourResource": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "start_time": {"type": ["string", "null"]}, "end_time": {"type": ["string", "null"]}}, "required": ["enabled", "start_time", "end_time"], "title": "AlertQuietHourResource"}, "AlertSettingResource": {"type": "object", "properties": {"module": {"type": "string"}, "action": {"type": "string"}, "enabled": {"type": "boolean"}}, "required": ["module", "action", "enabled"], "title": "AlertSettingResource"}, "CustomDomainResource": {"type": "object", "properties": {"id": {"type": "integer"}, "hostname": {"type": "string"}, "status": {"type": "string"}, "ssl": {"type": "object", "properties": {"validation_method": {"type": "string"}, "validation_string": {"type": ["string", "null"]}}, "required": ["validation_method", "validation_string"]}, "dns_records": {"type": "array", "items": {"anyOf": [{"type": "object", "properties": {"type": {"type": "string", "const": "ALIAS"}, "host": {"type": "string", "const": "@"}, "value": {"type": "string"}, "purpose": {"type": "string"}}, "required": ["type", "host", "value", "purpose"]}, {"type": "object", "properties": {"type": {"type": "string", "const": "CNAME"}, "host": {"type": "string"}, "value": {"type": "string"}, "purpose": {"type": "string", "const": "Apunta el subdominio al servicio de redirección de Funnelchat."}}, "required": ["type", "host", "value", "purpose"]}, {"type": "object", "properties": {"type": {"type": "string", "const": "TXT"}, "host": {"type": "string"}, "value": {"type": ["string", "null"]}, "purpose": {"type": "string", "const": "Validación de control de dominio (DCV) requerida por Cloudflare para emitir el certificado SSL."}}, "required": ["type", "host", "value", "purpose"]}]}}, "detected_provider": {"type": ["string", "null"], "description": "Best-effort DNS provider hint (change `custom-domains`,\nIncrement 6 — design.md D15/D17): pre-selects the frontend's\nprovider dropdown. `null` when detection failed/found no known\nmatch — the frontend falls back to the generic provider. NOT\npart of the cross-domain read contract (`communities` never\nreads it)."}, "created_at": {"type": ["string", "null"]}, "updated_at": {"type": ["string", "null"]}}, "required": ["id", "hostname", "status", "ssl", "dns_records", "detected_provider", "created_at", "updated_at"], "title": "CustomDomainResource"}, "DeviceConnectionEventEnum": {"type": "integer", "enum": [0, 1], "title": "DeviceConnectionEventEnum"}, "DeviceConnectionEventResource": {"type": "object", "properties": {"id": {"type": "integer"}, "phone": {"type": ["string", "null"]}, "event": {"$ref": "#/components/schemas/DeviceConnectionEventEnum"}, "created_at": {"type": "string"}}, "required": ["id", "phone", "event", "created_at"], "title": "DeviceConnectionEventResource"}, "DeviceFleetLogResource": {"type": "object", "properties": {"id": {"type": "integer"}, "method": {"type": "string"}, "url": {"type": ["string", "null"]}, "status_code": {"type": ["integer", "null"]}, "is_error": {"type": "boolean"}, "duration_ms": {"type": ["integer", "null"]}, "created_at": {"type": ["string", "null"]}, "request_payload": {"type": ["array", "null"], "items": {}}, "response_body": {"type": ["array", "null"], "items": {}}, "device_id": {"type": ["string", "null"]}, "device_label": {"type": ["string", "null"]}}, "required": ["id", "method", "url", "status_code", "is_error", "duration_ms", "created_at", "request_payload", "response_body", "device_id", "device_label"], "title": "DeviceFleetLogResource"}, "DeviceHealthDetailResource": {"type": "object", "properties": {"health": {"type": "string"}, "identity": {"type": "string"}, "entities": {"type": "string"}, "throughput_level": {"type": "string", "description": "Detail-only (meta-number-health-status, Increment 6 gap-closing\nfollow-up) — deliberately NOT part of `health`, which the\nlisting shares verbatim; see\nDeviceHealthSummaryBuilder::buildThroughputLevel()."}, "history": {"type": "array", "items": {"type": "object", "properties": {"field": {"type": "string"}, "previous_value": {"type": "string"}, "new_value": {"type": "string"}, "detail": {"type": ["string", "null"]}, "source": {"type": "string"}, "occurred_at": {"type": "string"}}, "required": ["field", "previous_value", "new_value", "detail", "source", "occurred_at"]}}, "meta_billing": {"type": ["object", "null"], "properties": {"waba_id": {"type": ["string", "null"]}, "business_id": {"type": ["string", "null"]}}, "required": ["waba_id", "business_id"]}}, "required": ["health", "identity", "entities", "throughput_level", "history", "meta_billing"], "title": "DeviceHealthDetailResource"}, "DeviceLogResource": {"type": "object", "properties": {"id": {"type": "integer"}, "method": {"type": "string"}, "url": {"type": ["string", "null"]}, "status_code": {"type": ["integer", "null"]}, "is_error": {"type": "boolean"}, "duration_ms": {"type": ["integer", "null"]}, "created_at": {"type": ["string", "null"]}, "request_payload": {"type": ["array", "null"], "items": {}}, "response_body": {"type": ["array", "null"], "items": {}}}, "required": ["id", "method", "url", "status_code", "is_error", "duration_ms", "created_at", "request_payload", "response_body"], "title": "DeviceLogResource"}, "DeviceResource": {"type": "object", "properties": {"id": {"type": "integer"}, "alias": {"type": "string"}, "image": {"type": "string"}, "color": {"type": "string"}, "number": {"type": "string"}, "normalized_number": {"type": "string", "description": "coexistence-connection-and-devices-ux, Increment 1 (design.md D1,\ntasks.md 1.1): purely additive, read-time-computed field used by\n`front/dashboard` to group Meta + QR devices sharing the same\nnumber into one card (`groupDevicesByNumber`). Reuses the SAME\n`PhoneNumberNormalizer` the backend already uses at write time\n(DeviceController) — never a second, drifting implementation.\n`null` when `number` is empty/null (device not yet connected)."}, "first_conecction_at": {"type": "string"}, "user_id": {"type": "integer"}, "provider": {"$ref": "#/components/schemas/WhatsAppProviderEnum"}, "waba_id": {"type": "string"}, "whatsapp_username": {"type": ["string", "null"]}, "is_business": {"type": "integer"}, "connected": {"type": "integer"}, "active": {"type": ["integer", "null"]}, "health": {"type": "string", "description": "meta-number-health-status, Increment 2 (design.md D4/D5/D6):\n`null` for any non-Meta device, WITHOUT exception. NEVER\ntriggers a Meta call — reads only the eager-loaded\n`healthStatus` relation (see DeviceController::index()) via\nDeviceHealthSummaryBuilder, the single source of truth for\nthis shape (also reused by the Increment 4 detail endpoint)."}, "number_key": {"type": ["string", "null"], "description": "Bolsa unica de numeros (whatsapp-preverified-number-assignment, Inc 9, D15/D29):\naditivo. Identidad persistente del numero (sobrevive a la desconexion) y el ultimo\nnumero para mostrar cuando `number` esta vacio. Las columnas estan en\n`Device::$hidden`; aca se exponen explicitamente."}, "number_key_confirmed": {"type": "string", "description": "Identidad confirmada = clave + `number_key_confirmed_at` (una clave sin fecha es\nla \"esperada\" de una fila hermana todavia sin conectar)."}, "is_coexistence": {"type": "boolean"}, "slot_state": {"type": "string", "description": "Inc 10 (D29): `free` / `reserved` / `pending` / `held`, computed only from the row's\ncolumns (no event probe, no extra query). A torn-down row without key from before\nthe backfill may look `free` (see DeviceSlotService::slotState())."}, "pool_suspended": {"type": "boolean", "description": "Inc 25 (audit 2026-10-15, D29/D36/D41/D42): suspension by the plan, the customer's\nkeep choice, and whether a QR connection of this number is allowed (computed over the\ntitular's rows already loaded by the listing; a single resource reads its number's\nrows with one query)."}, "pool_suspension_reason": {"type": ["string", "null"]}, "pool_keep": {"type": "boolean"}, "qr_allowed": {"type": "boolean"}, "can_change_to_meta": {"type": "boolean", "description": "D60 (contract with `front/dashboard`): a QR row of the titular with a confirmed\nnumber identity may change to Meta in place (`PUT devices/{id}` with the Embedded\nSignup code of the same number, own SIM). Never an extra query."}, "capabilities": {"type": "object", "description": "armar-funnelchat-f1-f2 (contract 1014 C4, design.md D3): what the\npublic API can do with this number, per provider. Additive; shared\nby the web routes and the `/api/v1` alias.", "properties": {"messages": {"type": "object", "properties": {"text": {"type": "boolean"}, "media": {"type": "boolean"}, "buttons": {"type": "boolean"}, "lists": {"type": "boolean"}, "templates": {"type": "boolean"}, "location": {"type": "boolean"}, "voice_note": {"type": "boolean"}, "polls": {"type": "boolean"}, "events": {"type": "boolean"}, "contact_card": {"type": "boolean"}, "reactions": {"type": "boolean"}}, "required": ["text", "media", "buttons", "lists", "templates", "location", "voice_note", "polls", "events", "contact_card", "reactions"]}, "groups": {"type": "object", "properties": {"create": {"type": "boolean"}, "add_participants": {"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["direct", "invite"]}]}, "remove_participants": {"type": "boolean"}, "admins": {"type": "boolean"}, "photo": {"type": "boolean"}, "settings": {"type": "boolean"}, "invite_link": {"type": "boolean"}, "send": {"type": "boolean"}, "join_requests": {"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["list", "list_and_decide"]}]}}, "required": ["create", "add_participants", "remove_participants", "admins", "photo", "settings", "invite_link", "send", "join_requests"]}, "statuses": {"type": "array", "items": {"type": "string"}}}, "required": ["messages", "groups", "statuses"]}}, "required": ["id", "alias", "image", "color", "number", "normalized_number", "first_conecction_at", "user_id", "provider", "waba_id", "whatsapp_username", "is_business", "connected", "active", "health", "number_key", "number_key_confirmed", "is_coexistence", "slot_state", "pool_suspended", "pool_suspension_reason", "pool_keep", "qr_allowed", "can_change_to_meta", "capabilities"], "title": "DeviceResource"}, "DisconnectSurveyResource": {"type": "object", "properties": {"pending": {"type": "boolean"}, "phone": {"type": ["string", "null"]}}, "required": ["pending", "phone"], "title": "DisconnectSurveyResource"}, "ExtensionTokenResource": {"type": "object", "properties": {"token": {"type": "string"}, "expiresAt": {"type": "integer"}, "expiresInSeconds": {"type": "integer"}}, "required": ["token", "expiresAt", "expiresInSeconds"], "title": "ExtensionTokenResource"}, "SslValidationMethod": {"type": "string", "description": "SSL Domain Control Validation (DCV) method requested from Cloudflare for SaaS when creating or re-validating a Custom Hostname. `HTTP` is the default requested by `CloudflareCustomHostnameClient::create()` (design.md D9 — supersedes the original TXT-only MVP default): the certificate is issued once the CNAME resolves and traffic reaches Cloudflare, with no DNS TXT record required for the common case. `TXT` remains available as an explicit fallback via the `switch-validation` endpoint (design.md D11) for domains whose CNAME does not yet route to Cloudflare.\n", "enum": ["TXT", "HTTP"], "title": "SslValidationMethod"}, "StoreAlertDestinationRequest": {"type": "object", "properties": {"type": {"$ref": "#/components/schemas/AlertDestinationType"}, "destination": {"type": "string", "maxLength": 255}, "name": {"type": ["string", "null"], "maxLength": 120}}, "required": ["type", "destination"], "title": "StoreAlertDestinationRequest"}, "StoreCustomDomainRequest": {"type": "object", "description": "Validates the payload for `POST /custom-domains`.\n\nSanitizes `hostname` in `prepareForValidation()` BEFORE the `unique`/`regex`\nrules run, so:\n  - the uniqueness check compares against the canonical (sanitized) form —\n    `https://Go.MiMarca.com/landing` and `go.mimarca.com` are the same\n    hostname for this check.\n  - a duplicate or malformed hostname fails validation (422) BEFORE the\n    controller method ever runs, guaranteeing Cloudflare is never called\n    for those cases (spec scenarios \"Duplicate hostname rejected\" /\n    \"Invalid hostname rejected\").\n\nAuthorization (admin-only, payment-valid) is enforced at the route-group\nlevel (`can:user-admin` + `can:payment-valid`), not here — same pattern as\n`StorePlanRequest`/`UpdatePlanRequest`.", "properties": {"hostname": {"type": "string", "maxLength": 255}}, "required": ["hostname"], "title": "StoreCustomDomainRequest"}, "StoreDisconnectReasonRequest": {"type": "object", "description": "Validates the payload for `POST /devices/{device}/disconnect-survey`.\n\nAuthorization is delegated to the controller (`authorize('update', $device)`),\nmatching the pattern used across the Device module's other write endpoints.\n\n`reason` accepts only `DeviceDisconnectReasonEnum::writableValues()`\n(`{1, 2, 4, 5}`) — change `disconnect-survey-block-type`: the legacy `3`\n(`whatsapp_blocked`, ambiguous permanent/temporary) is rejected with 422\non write, even though it remains a valid value for reads/metrics.", "properties": {"reason": {"type": "integer", "enum": ["1", "2", "4", "5"]}}, "required": ["reason"], "title": "StoreDisconnectReasonRequest"}, "StoreWebhookEndpointRequest": {"type": "object", "properties": {"url": {"type": "string", "format": "uri", "description": "`https` obligatorio. La comprobación de a DÓNDE resuelve la hace\n`SsrfGuard` en el controlador: acá sólo se valida la forma.", "maxLength": 2048}, "description": {"type": ["string", "null"], "maxLength": 160}, "events": {"type": "array", "description": "Suscripciones iniciales, opcionales: un endpoint puede nacer sin\nninguna y suscribirse después. Lo que NO puede es nacer suscripto a\ntodo por omisión — eso sería lo contrario de \"por demanda\".", "items": {"type": "string", "enum": ["device.disconnected", "device.connected", "billing.payment_failed", "billing.plan_blocked", "billing.upcoming_charge", "subscription.expiring", "subscription.trial_ending", "subscription.reactivated", "subscription.cancellation_confirmed", "subscription.dormant_ending", "ai_quota.low_balance", "ai_quota.topup_succeeded", "ai_quota.topup_failed", "mac.usage_80", "mac.usage_100", "mac.expiring", "account.blocked"]}, "maxItems": 50}}, "required": ["url"], "title": "StoreWebhookEndpointRequest"}, "StoreWebhookSubscriptionRequest": {"type": "object", "description": "El cuerpo de `POST api/webhooks`. La autorización real la hacen los gates de\nla ruta (`can:user-admin`) y `AssertsAccountOwner` en el controlador.", "properties": {"url": {"type": "string", "format": "uri", "maxLength": 2048}, "description": {"type": ["string", "null"], "maxLength": 160}, "events": {"type": "array", "items": {"type": "string", "enum": ["device.disconnected", "device.connected", "billing.payment_failed", "billing.plan_blocked", "billing.upcoming_charge", "subscription.expiring", "subscription.trial_ending", "subscription.reactivated", "subscription.cancellation_confirmed", "subscription.dormant_ending", "ai_quota.low_balance", "ai_quota.topup_succeeded", "ai_quota.topup_failed", "mac.usage_80", "mac.usage_100", "mac.expiring", "account.blocked", "message.received", "flow.completed", "broadcast.finished", "contact.stage_changed", "message.status_changed", "conversation.closed", "conversation.assigned", "contact.tag_added", "broadcast.paused", "contact.tag_removed", "contact.created", "template.status_changed", "flow.failed", "whatsapp_form.completed", "group.created", "group.invite_link_down", "group.invite_link_renewed", "group_participant.joined", "group_participant.left", "scheduled_message.group_delivery_failed", "scheduled_message.response_received", "poll.voted", "pipeline.finished", "group.blocked", "group.recovered", "campaign.status_changed", "scheduled_message.finished", "group_action.failed", "daily_report.generated", "campaign.no_groups_available", "group.full", "group_action.completed", "group.message_received", "group.reaction_added", "group_join_request.created", "group_join_request.revoked"]}, "minItems": 1, "maxItems": 50, "uniqueItems": true}, "filters": {"type": "object", "properties": {"campaign_ids": {"type": ["array", "null"], "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$", "minLength": 6, "maxLength": 64}, "maxItems": 100}}}}, "required": ["url", "events"], "title": "StoreWebhookSubscriptionRequest"}, "SwitchValidationMethodRequest": {"type": "object", "description": "Validates the payload for `POST /custom-domains/{customDomain}/switch-validation`\n(design.md D11): switches a domain's SSL Domain Control Validation (DCV)\nmethod between `HTTP` (default) and `TXT` (fallback for domains whose CNAME\ndoes not yet route to Cloudflare).\n\nAuthorization (admin-only, payment-valid) is enforced at the route-group\nlevel (`can:user-admin` + `can:payment-valid`); ownership of the target\ndomain is enforced by the controller's `findOwned()` (404 if not the\nauthenticated admin's own domain) — same pattern as `verify`/`destroy`.", "properties": {"method": {"$ref": "#/components/schemas/SslValidationMethod"}}, "required": ["method"], "title": "SwitchValidationMethodRequest"}, "UpdateAlertDailyReportRequest": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "send_time": {"type": ["string", "null"]}, "all_campaigns": {"type": "boolean"}, "campaign_ids": {"type": ["array", "null"], "items": {"type": "integer"}}}, "required": ["enabled", "all_campaigns"], "title": "UpdateAlertDailyReportRequest"}, "UpdateAlertDestinationRequest": {"type": "object", "properties": {"name": {"type": ["string", "null"], "description": "name is nullable per schema — allow sending null to clear the field.", "maxLength": 120}, "destination": {"type": "string", "description": "`destination` solo se acepta para destinos `webhook` (el controller\nrechaza el resto con 422). La forma real —https y que resuelva a\nuna IP publica— la decide `SsrfGuard` en el controller, no una\nregla de validacion: el guard resuelve DNS, y eso no se expresa\ncon `url`/`starts_with`.", "maxLength": 255}}, "title": "UpdateAlertDestinationRequest"}, "UpdateAlertQuietHourRequest": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "start_time": {"type": ["string", "null"]}, "end_time": {"type": ["string", "null"]}}, "required": ["enabled"], "title": "UpdateAlertQuietHourRequest"}, "UpdateWebhookEndpointEventsRequest": {"type": "object", "description": "El cuerpo de `PUT api/v1/webhooks/endpoints/{id}/events`.\n\nAcepta las dos formas, porque las dos son legítimas y pedirle al cliente que\nelija una sería gratuito:\n\n  `{\"events\": [\"device.disconnected\"]}`\n      → suscribe a esos tipos, activados, y da de baja los que no estén.\n\n  `{\"events\": [{\"event_type\": \"device.disconnected\", \"enabled\": false}]}`\n      → lo mismo, pero eligiendo el interruptor de cada uno. Un tipo con\n        `enabled: false` queda suscripto y apagado: no se entrega, pero la\n        configuración sobrevive a volver a prenderlo.", "properties": {"events": {"type": "array", "items": {"type": "object", "properties": {"event_type": {"type": "string", "enum": ["device.disconnected", "device.connected", "billing.payment_failed", "billing.plan_blocked", "billing.upcoming_charge", "subscription.expiring", "subscription.trial_ending", "subscription.reactivated", "subscription.cancellation_confirmed", "subscription.dormant_ending", "ai_quota.low_balance", "ai_quota.topup_succeeded", "ai_quota.topup_failed", "mac.usage_80", "mac.usage_100", "mac.expiring", "account.blocked"]}, "enabled": {"type": "boolean"}}, "required": ["event_type"]}, "maxItems": 50}}, "required": ["events"], "title": "UpdateWebhookEndpointEventsRequest"}, "UpdateWebhookEndpointRequest": {"type": "object", "properties": {"url": {"type": "string", "format": "uri", "maxLength": 2048}, "description": {"type": ["string", "null"], "maxLength": 160}, "status": {"type": "string", "description": "Prender y apagar el endpoint entero, sin perder sus suscripciones\nni su secreto.", "enum": ["enabled", "disabled"]}}, "title": "UpdateWebhookEndpointRequest"}, "UpdateWebhookSubscriptionRequest": {"type": "object", "description": "El cuerpo de `PUT api/webhooks/{id}`: reemplaza `url`, `events`, `description`\ny `filters`; `status` es opcional y solo admite `active` o `paused` (el cliente\nno puede dejarla `disabled`).", "properties": {"url": {"type": "string", "format": "uri", "maxLength": 2048}, "description": {"type": ["string", "null"], "maxLength": 160}, "events": {"type": "array", "items": {"type": "string", "enum": ["device.disconnected", "device.connected", "billing.payment_failed", "billing.plan_blocked", "billing.upcoming_charge", "subscription.expiring", "subscription.trial_ending", "subscription.reactivated", "subscription.cancellation_confirmed", "subscription.dormant_ending", "ai_quota.low_balance", "ai_quota.topup_succeeded", "ai_quota.topup_failed", "mac.usage_80", "mac.usage_100", "mac.expiring", "account.blocked", "message.received", "flow.completed", "broadcast.finished", "contact.stage_changed", "message.status_changed", "conversation.closed", "conversation.assigned", "contact.tag_added", "broadcast.paused", "contact.tag_removed", "contact.created", "template.status_changed", "flow.failed", "whatsapp_form.completed", "group.created", "group.invite_link_down", "group.invite_link_renewed", "group_participant.joined", "group_participant.left", "scheduled_message.group_delivery_failed", "scheduled_message.response_received", "poll.voted", "pipeline.finished", "group.blocked", "group.recovered", "campaign.status_changed", "scheduled_message.finished", "group_action.failed", "daily_report.generated", "campaign.no_groups_available", "group.full", "group_action.completed", "group.message_received", "group.reaction_added", "group_join_request.created", "group_join_request.revoked"]}, "minItems": 1, "maxItems": 50, "uniqueItems": true}, "filters": {"type": "object", "properties": {"campaign_ids": {"type": ["array", "null"], "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$", "minLength": 6, "maxLength": 64}, "maxItems": 100}}}, "status": {"type": "string", "enum": ["active", "paused"]}}, "required": ["url", "events"], "title": "UpdateWebhookSubscriptionRequest"}, "UpsertAlertSettingRequest": {"type": "object", "properties": {"module": {"$ref": "#/components/schemas/AlertModule"}, "action": {"$ref": "#/components/schemas/AlertAction"}, "enabled": {"type": "boolean"}}, "required": ["module", "action", "enabled"], "title": "UpsertAlertSettingRequest"}, "UserResource": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}, "admin": {"anyOf": [{"type": "string"}, {"type": "integer", "enum": [0]}]}, "role": {"type": "string"}, "token": {"anyOf": [{"type": "string"}, {"type": "string", "enum": [""]}]}, "first_login_at": {"type": "string"}, "last_login_at": {"type": "string"}, "first_login_form": {"type": ["boolean", "null"]}, "without_business_type": {"type": "boolean"}, "image": {"type": "string"}, "phone": {"type": "string"}, "timezone": {"type": "string"}, "locale": {"anyOf": [{"type": "string"}, {"type": "string", "enum": ["es"]}]}, "owner_id": {"type": "string"}, "owner": {"type": ["object", "null"], "description": "Owner (principal) account exposure — backoffice-legacy-parity Increment 4.\nGuard: only populated when the `owner` relation is explicitly eager-loaded\n(UserController::index/getUserDetails via `->with('owner:id,name,email')`).\nConsumers that don't eager-load it (e.g. /me, front/account) always get\n`null` here — the resource NEVER lazy-loads the relation itself, so no\nN+1 is possible regardless of caller.", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}}, "required": ["id", "name", "email"]}, "plan": {"anyOf": [{"type": "array"}, {"type": "string", "enum": [""]}]}, "effective_business_numbers_limit": {"type": ["integer", "null"]}, "effective_agents_limit": {"type": ["integer", "null"]}, "advisor_scope": {"type": "string"}, "show_advisor_name": {"type": "string"}, "contacts": {"type": ["string", "integer"]}, "groups": {"type": ["string", "integer"]}, "devices": {"type": "string"}, "additional_devices": {"type": "string"}, "agents": {"type": "string"}, "payment_till": {"type": "string"}, "next_billing_at": {"type": "string"}, "last_payment_at": {"anyOf": [{"type": "string"}, {"type": "string", "enum": [""]}]}, "feature_versions": {"type": "string"}, "effective_feature_versions": {"type": "object", "description": "Effective feature versions — beta-program-catalog-membership.\nMap of feature_key => bool (true = effective version is beta),\nresolved via VersionCheckService::isUserMigratedBatch()\nprecedence (own row -> owner row -> catalog default_version,\nsame as isUserMigrated() per feature, batched — see that\nmethod's docblock for the query-budget rationale). Unlike\n`feature_versions` above, this is not gated by whenLoaded —\nthe service performs its own cached lookup rather than\nrelying on an eager-loaded relation. Lets agents (read-only)\nsee the version they inherit from their owner without\nexposing the admin-gated catalog endpoints. Scoped to the GET /me and GET /user(+/v1 alias) endpoints —\nthe two self-serialization routes that need it per design.md\nD11 — rather than computed unconditionally on every\nUserResource serialization. Admin/agent LIST endpoints\n(GET /users, GET /users/agents) also render via UserResource;\ncomputing this per row there would still cost extra queries\nwith no caching benefit across distinct users, confirmed\nempirically to blow the pre-existing /users N+1 smoke-test\nbudget (35 queries) even for a single self-referencing admin\nrow inside the list. Path-gating avoids paying that cost\nanywhere it isn't needed.", "additionalProperties": {"type": "boolean"}}, "workspaces_enabled": {"type": "string", "description": "workspaces-and-roles Inc 2 (design.md D2, tasks.md 2.8). Same\npath-gating as effective_feature_versions above (GET /me,\nGET /user only) to avoid an extra per-row cache lookup on\nlist endpoints (GET /users, GET /users/agents) that also\nserialize via UserResource."}, "panel_usage": {"type": ["object", "null"], "description": "Progreso de uso del panel. Va con `whenLoaded` para que la lista del\nbackoffice no dispare una consulta por fila: sin el eager load la\nclave no aparece, en vez de aparecer con un cero inventado.", "properties": {"percentage": {"type": "string"}, "ceiling": {"type": "string"}, "used_count": {"type": "string"}, "reachable_count": {"type": "string"}, "total_count": {"type": "string"}, "tier": {"type": "string"}, "is_agent": {"type": "string"}, "blocks": {"type": "string"}, "computed_at": {"type": ["string", "null"]}}, "required": ["percentage", "ceiling", "used_count", "reachable_count", "total_count", "tier", "is_agent", "blocks", "computed_at"]}, "created_at": {"anyOf": [{"type": "string"}, {"type": "string", "enum": [""]}]}, "updated_at": {"anyOf": [{"type": "string"}, {"type": "string", "enum": [""]}]}, "is_dormant": {"type": "boolean", "description": "Dormant state — billing-dormant-account.\nResolution order:\n1. subscriptions relation is loaded (AuthController, /me route) → filter in PHP.\n2. dormant_row side-channel set by UserController::getUserDetails() → back-compat.\n3. Neither loaded → defaults to false / null."}, "dormant_expires_at": {"type": ["string", "null"]}, "dormant_provider": {"type": "string", "description": "Dormant provider + eligibility — dormant-hotmart-support D6.\ndormant_provider is always resolvable (plain column read); it mirrors\nthe same binary criterion BillingDormantService uses to branch dormant.", "enum": ["hotmart", "stripe"]}, "can_enter_dormant": {"type": ["boolean", "null"]}, "stripe_migration_incomplete": {"type": "boolean", "description": "Hotmart -> Stripe migration saved without a linked subscription\n(plan-change-full-mac-grant-and-stripe-migration-close, D6). The boolean is\nharmless everywhere; the reason code is only emitted to admins (backoffice)."}, "stripe_migration_incomplete_reason": {"type": "string"}, "mac_enforcement_enabled": {"type": "boolean", "description": "MAC enforcement fields — mac-rollover-and-admin Inc 5.\nmac_enforcement_enabled: cast to bool so null (pre-migration) renders as false.\navailable_mac_buckets_sum: single extra query; acceptable on /me (single user)."}, "available_mac_buckets_sum": {"type": "integer"}, "password_set": {"type": "boolean"}, "password_set_at": {"type": ["string", "null"]}}, "required": ["id", "name", "email", "admin", "role", "token", "first_login_at", "last_login_at", "first_login_form", "without_business_type", "image", "phone", "timezone", "locale", "owner_id", "owner", "plan", "advisor_scope", "show_advisor_name", "contacts", "groups", "devices", "additional_devices", "agents", "payment_till", "last_payment_at", "created_at", "updated_at", "is_dormant", "dormant_expires_at", "dormant_provider", "stripe_migration_incomplete", "mac_enforcement_enabled", "available_mac_buckets_sum"], "title": "UserResource"}, "VerifyAlertOtpRequest": {"type": "object", "properties": {"code": {"type": "string"}}, "required": ["code"], "title": "VerifyAlertOtpRequest"}, "WabaCapiDatasetResource": {"type": "object", "properties": {"waba_id": {"type": "string"}, "dataset_id": {"type": ["string", "null"]}, "scope_ok": {"type": "boolean"}, "consented_at": {"type": ["string", "null"]}, "last_verified_at": {"type": ["string", "null"]}}, "required": ["waba_id", "dataset_id", "scope_ok", "consented_at", "last_verified_at"], "title": "WabaCapiDatasetResource"}, "WebhookDeliveryResource": {"type": "object", "properties": {"id": {"type": "integer"}, "event_id": {"type": "string", "description": "El id del sobre: agrupa los intentos de un mismo evento y es el\nmismo valor que el receptor vio en el cuerpo."}, "event_type": {"type": "string"}, "attempt": {"type": "integer"}, "url": {"type": "string"}, "response_status": {"type": ["integer", "null"]}, "succeeded": {"type": "boolean"}, "error": {"type": ["string", "null"]}, "duration_ms": {"type": ["integer", "null"]}, "attempted_at": {"type": "string"}}, "required": ["id", "event_id", "event_type", "attempt", "url", "response_status", "succeeded", "error", "duration_ms", "attempted_at"], "title": "WebhookDeliveryResource"}, "WebhookEndpointResource": {"type": "object", "properties": {"id": {"type": "integer"}, "url": {"type": "string"}, "description": {"type": ["string", "null"]}, "status": {"type": "string"}, "disabled_reason": {"type": ["string", "null"]}, "events": {"type": "array", "items": {"type": "object", "properties": {"event_type": {"type": "string"}, "enabled": {"type": "boolean"}}, "required": ["event_type", "enabled"]}}, "health": {"type": "object", "properties": {"consecutive_failures": {"type": "integer"}, "last_delivery_at": {"type": ["string", "null"]}, "last_success_at": {"type": ["string", "null"]}}, "required": ["consecutive_failures", "last_delivery_at", "last_success_at"]}, "secret_rotated_at": {"type": ["string", "null"]}, "created_at": {"type": ["string", "null"]}, "updated_at": {"type": ["string", "null"]}}, "required": ["id", "url", "description", "status", "disabled_reason", "health", "secret_rotated_at", "created_at", "updated_at"], "title": "WebhookEndpointResource"}, "WebhookSubscriptionCreatedResource": {"type": "object", "properties": {"id": {"type": "string"}, "url": {"type": "string"}, "description": {"type": ["string", "null"]}, "events": {"type": "array", "items": {}}, "filters": {"description": "Siempre un objeto: `{}` si no hay filtro.", "anyOf": [{"type": "object", "additionalProperties": {}}, {"type": "null"}, {"type": "object"}]}, "status": {"type": "string"}, "disabled_reason": {"type": ["string", "null"]}, "last_delivery_at": {"type": ["string", "null"]}, "last_success_at": {"type": ["string", "null"]}, "secret_rotated_at": {"type": ["string", "null"]}, "created_at": {"type": ["string", "null"]}, "updated_at": {"type": ["string", "null"]}, "migration": {"type": ["object", "null"], "properties": {"origin": {"type": "string"}, "status": {"type": "string"}, "activated_via": {"type": ["string", "null"]}, "activated_at": {"type": ["string", "null"]}, "last_result": {"type": ["string", "null"]}}, "required": ["origin", "status", "activated_via", "activated_at", "last_result"]}, "secret": {"type": "string"}, "secret_notice": {"type": "string", "const": "Guardá este secreto ahora: es la única vez que se muestra. Con él validás la firma del header de cada entrega."}}, "required": ["id", "url", "description", "filters", "status", "disabled_reason", "last_delivery_at", "last_success_at", "secret_rotated_at", "created_at", "updated_at", "migration", "secret", "secret_notice"], "title": "WebhookSubscriptionCreatedResource"}, "WebhookSubscriptionDeliveryResource": {"type": "object", "properties": {"id": {"type": "integer"}, "event_id": {"type": "string", "description": "El id del sobre: agrupa los intentos de un mismo evento y es el\nmismo valor que el receptor vio en el cuerpo."}, "event_type": {"type": "string"}, "attempt": {"type": "integer"}, "url": {"type": "string"}, "response_status": {"type": ["integer", "null"]}, "succeeded": {"type": "boolean"}, "error": {"type": ["string", "null"]}, "duration_ms": {"type": ["integer", "null"]}, "attempted_at": {"type": "string"}}, "required": ["id", "event_id", "event_type", "attempt", "url", "response_status", "succeeded", "error", "duration_ms", "attempted_at"], "title": "WebhookSubscriptionDeliveryResource"}, "WebhookSubscriptionResource": {"type": "object", "properties": {"id": {"type": "string"}, "url": {"type": "string"}, "description": {"type": ["string", "null"]}, "events": {"type": "array", "items": {}}, "filters": {"description": "Siempre un objeto: `{}` si no hay filtro.", "anyOf": [{"type": "object", "additionalProperties": {}}, {"type": "null"}, {"type": "object"}]}, "status": {"type": "string"}, "disabled_reason": {"type": ["string", "null"]}, "last_delivery_at": {"type": ["string", "null"]}, "last_success_at": {"type": ["string", "null"]}, "secret_rotated_at": {"type": ["string", "null"]}, "created_at": {"type": ["string", "null"]}, "updated_at": {"type": ["string", "null"]}, "migration": {"type": ["object", "null"], "properties": {"origin": {"type": "string"}, "status": {"type": "string"}, "activated_via": {"type": ["string", "null"]}, "activated_at": {"type": ["string", "null"]}, "last_result": {"type": ["string", "null"]}}, "required": ["origin", "status", "activated_via", "activated_at", "last_result"]}}, "required": ["id", "url", "description", "filters", "status", "disabled_reason", "last_delivery_at", "last_success_at", "secret_rotated_at", "created_at", "updated_at", "migration"], "title": "WebhookSubscriptionResource"}, "WhatsAppProviderEnum": {"type": "integer", "enum": [1, 2, 3, 4], "title": "WhatsAppProviderEnum"}, "WhatsappCostSummaryResource": {"type": "object", "additionalProperties": {}, "title": "WhatsappCostSummaryResource"}}, "responses": {"AuthorizationException": {"description": "Authorization error", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "description": "Error overview."}}, "required": ["message"]}}}}, "AuthenticationException": {"description": "Unauthenticated", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "description": "Error overview."}}, "required": ["message"]}}}}, "ModelNotFoundException": {"description": "Not found", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "description": "Error overview."}}, "required": ["message"]}}}}, "ValidationException": {"description": "Validation error", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "description": "Errors overview."}, "errors": {"type": "object", "description": "A detailed description of each field that failed validation.", "additionalProperties": {"type": "array", "items": {"type": "string"}}}}, "required": ["message", "errors"]}}}}}}}