{"openapi":"3.1.0","info":{"title":"AMAI Voice API","version":"1.0.0","description":"API REST de AMAI Voice para consultar tu historial de llamadas y automatizar mensajería de WhatsApp y flujos de voz sobre tu número. Autenticación por API key (`Authorization: Bearer amai_<key>`), scoped a tu tenant: cada clave solo ve los datos del tenant al que pertenece. El token de Meta lo custodia y rota AMAI: nunca manejas un token de Meta.\n\n### Si contrataste a través de un proveedor\n\nEsta plataforma se revende con marca propia. Si llegaste aquí desde el panel de un proveedor, **tu dominio de API es el suyo, no `voice.amai.run`** — cámbialo en el selector de servidor de arriba; el contrato es idéntico. Tu soporte y tu facturación son también de ese proveedor: el contacto que figura en este documento es el del fabricante de la plataforma.\n\n### Los dos `403`, y por qué la consola «probar» puede darte uno\n\nUn `403` de esta API tiene dos causas, y **se arreglan en sitios distintos**:\n\n- **`insufficient_scope`** — a tu clave le falta el permiso de esa operación. Lo arregla quien emitió la clave: pide una nueva con ese scope.\n- **`capability_required`** — **el plan de la cuenta** no incluye esa función. Emitir una clave nueva **no sirve de nada**: hay que habilitar la función en la cuenta.\n\nLa capacidad se comprueba sobre el **tenant**, no sobre quien llama. Por eso, si pruebas un endpoint desde la consola de esta página estando perfectamente logueado y con todos tus permisos, **puedes recibir `capability_required` igualmente**. No es que la API esté rota: es que esa función no está en el plan de la cuenta. La API respeta el mismo modelo de planes que el panel.\n\n### Presupuesto de peticiones\n\n**60 peticiones por minuto, cobradas a tu clave.** Lo que hagan otros clientes no te consume cuota, y salir por una IP compartida (el NAT de una oficina, varios contenedores tras la misma salida) no te penaliza. Si autenticas por sesión en vez de por clave, el presupuesto va a tu usuario.\n\nPor delante hay una barrera de admisión de **600 por minuto y dirección IP**, anterior a comprobar la credencial. Su único fin es que una avalancha sin autenticar no llegue a la tabla de claves; no es tu cuota, y no deberías verla nunca.\n\nUn `403` por permisos insuficientes **no gasta presupuesto**: una integración mal configurada no puede dejarte sin cuota a base de peticiones que iban a ser rechazadas igualmente.\n\n**El `429` tiene dos formas de cuerpo según la operación**, y esto es historia, no diseño. La forma normal —y la que usan todas las operaciones nuevas— es `{error:{code:\"rate_limited\",message}}`. Un grupo de operaciones antiguas responde en cambio con `error` como **cadena** y un `retryAfter` al lado; se reconocen porque su `429` en este documento declara el esquema `LegacyRateLimitError`. Las dos traen `Retry-After` y `X-RateLimit-Reset`. Si escribes un manejador común, contempla ambas.\n\n### Webhooks: eventos que se pueden contratar pero NO se emiten\n\nLa configuración de webhooks acepta ocho eventos. **Solo cuatro se envían de verdad**: `call.ended`, `call.failed`, `agent.created` y `whatsapp.message.received` — los cuatro documentados abajo.\n\nEstos otros cuatro se pueden seleccionar y **no llegan nunca**: `call.started`, `agent.updated`, `billing.charged` y `billing.low_balance`. No hay ningún punto del código que los dispare. Si tu integración los espera, esperará indefinidamente: usa `GET /api/v1/calls` para el consumo y para el coste.","contact":{"name":"AMAI Solutions","email":"info@amai.solutions","url":"https://voice.amai.run"}},"servers":[{"url":"https://{api_domain}","description":"Tu dominio de API. Si contrataste AMAI Voice a través de un proveedor con marca propia, sustitúyelo por el dominio que te haya dado (lo tienes en tu panel, en Ajustes → API Keys); el contrato es el mismo.","variables":{"api_domain":{"default":"voice.amai.run","description":"Dominio desde el que se sirve la API. `voice.amai.run` y `api.voice.amai.run` son equivalentes; un revendedor con marca propia usará el suyo."}}}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Llamadas","description":"Historial de llamadas del tenant con desglose de coste y agregados del periodo completo. Sustituye a la descarga manual del CSV del panel."},{"name":"WhatsApp","description":"Enviar y recibir mensajes de WhatsApp, y gestionar plantillas."},{"name":"Contactos","description":"Identificación y directorio de contactos del tenant."},{"name":"Marca blanca","description":"La marca y el empaquetado de las cuentas del árbol: nombre comercial, logo, colores, dominio propio con su verificación por DNS, y las tres verjas que deciden si una cuenta admite altas y qué puede contratar. **El identificador tiene que colgar de la cuenta que llama**, o ser ella misma; cualquier otro responde `404` sin confirmar que exista. `custom_domain_verified` no se escribe desde ninguna operación: lo pone la comprobación del registro TXT."},{"name":"Utilidades","description":"Calendario y email para flujos de agente."},{"name":"Conversations","description":"La cápsula de desarrollo: bandeja unificada, mensajes, cesión humana, números conectados, catálogo de eventos y reenvío de entregas. **Toda esta sección exige el interruptor `CONVERSATIONS_API_ENABLED`**; con él apagado responde `404`, indistinguible de no estar desplegada. Su paginación es **por cursor**, no por `offset`, porque una bandeja se reordena mientras se lee."},{"name":"Listas","description":"Listas de contactos y su pertenencia — el bloque con el que se arma la audiencia de una campaña."},{"name":"Agentes","description":"Edición del contenido de un agente de voz (nombre, saludo, prompt, idioma, voz). ⚠ La superficie de ESCRITURA (POST/PATCH sobre contactos, listas y agentes) está detrás del flag `PUBLIC_API_WRITE_ENABLED`. Las operaciones que gastan (activar un agente, comprar un número, lanzar una llamada) siguen siendo exclusivas del panel: esos caminos llevan guardas de saldo y concurrencia que no se replican aquí."},{"name":"Claves de API","description":"Rotación de la PROPIA credencial. No se listan, no se crean y no se revocan las ajenas por API: eso se hace en el panel, donde hay una persona identificada. Una API que deja fabricar credenciales convierte cualquier filtración en persistencia."}],"paths":{"/api/v1/calls":{"get":{"tags":["Llamadas"],"summary":"Listar llamadas con coste y agregados del periodo","description":"Devuelve el historial de llamadas del tenant dueño de la API key, de la más reciente a la más antigua, con el desglose de coste de cada llamada y un `summary` con los agregados de **todo el rango filtrado** (no de la página).\n\nEs el equivalente por API del CSV que se descarga desde el panel de llamadas, y un superconjunto suyo: añade `ended_at`, `duration_sec`, el desglose `cost` y el `summary`.\n\n**Aislamiento:** el tenant se deriva SIEMPRE de la clave autenticada. No hay ningún parámetro para consultar otro tenant.\n\n**Coste por minuto:** `summary.average_cost_per_minute_usd` = `total_cost_usd / total_billed_minutes`. Si el periodo no tiene minutos facturados el campo vale `null`, nunca `0`.","operationId":"listCalls","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora."},{"name":"direction","in":"query","schema":{"type":"string","enum":["inbound","outbound"]},"description":"Filtra por dirección de la llamada."},{"name":"status","in":"query","schema":{"type":"string","enum":["completed","failed","timeout"]},"description":"Filtra por resultado, con la misma semántica compuesta que el panel. Ojo: son los valores de FILTRO; el campo `status` de cada llamada usa su propio enum (ver esquema `Call`)."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar. Ver la guía «Historial de llamadas por API»."}],"responses":{"200":{"description":"Página de llamadas más los agregados del rango completo","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallsResponse"},"example":{"data":[{"id":"call_a1b2c3d4","started_at":"2026-07-23T14:32:11Z","ended_at":"2026-07-23T14:33:17Z","direction":"outbound","from":"+573501234567","to":"+34600111222","destination_label":"Spain Mobile","duration_sec":66,"billed_sec":66,"status":"completed","hangup_cause":"NORMAL_CLEARING","sip_account":"1001","call_origin":"ai_agent","cost":{"currency":"USD","rate_per_minute":0.012,"telephony_usd":0.0132,"ai_usd":0.045,"total_usd":0.0582}}],"pagination":{"limit":50,"offset":0,"total":412,"has_more":true},"summary":{"period":{"start":"2026-07-01T00:00:00Z","end":"2026-07-31T23:59:59Z"},"total_calls":412,"connected_calls":388,"failed_calls":24,"total_billed_seconds":24870,"total_billed_minutes":414.5,"telephony_cost_usd":8.1,"ai_cost_usd":4.3402,"total_cost_usd":12.4402,"average_cost_per_minute_usd":0.03}}}}},"400":{"description":"Parámetro mal formado. Un parámetro inválido nunca se ignora: ignorarlo devolvería datos de otro periodo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"start_date debe ser ISO-8601 (2026-07-01 o 2026-07-01T00:00:00Z)"}}},"invalid_range":{"summary":"Rango mayor de 366 días","value":{"error":{"code":"invalid_range","message":"El rango no puede superar 366 días"}}},"invalid_field":{"summary":"Valor no permitido en un filtro","value":{"error":{"code":"invalid_field","message":"direction debe ser inbound u outbound"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"La clave no tiene el scope `calls:read`. Solo puede ocurrir con el enforcement de scopes activado en la instancia.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_scope","message":"La API key no tiene el scope calls:read"}}}}},"429":{"description":"Presupuesto de 60 peticiones/minuto agotado. Reintenta con backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"Demasiadas peticiones"}}}}},"500":{"description":"`internal_error` — fallo al obtener el historial. Reintentable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error al obtener el historial de llamadas."}}}}}}}},"/api/v1/calls/{call_id}/member":{"put":{"tags":["Llamadas"],"summary":"Asignar personas del equipo a una llamada","description":"Vincula de 1 a 5 miembros del equipo a una llamada, para que aparezca como suya en el panel y reciban el aviso en tiempo real. Pensado para que un agente o un flujo automático asigne el seguimiento sin que nadie entre al panel.\n\n**Reemplaza, no acumula:** por defecto, asignar sobre una llamada que ya tenía otra persona la sustituye y lo deja anotado en la auditoría. Si prefieres que falle en vez de reemplazar en silencio, manda `?strict=true` y responderá `409`.\n\n**Reintentos seguros:** manda la cabecera `Idempotency-Key` y un reintento devuelve el resultado de la primera vez en lugar de volver a escribir.","operationId":"linkCallMembers","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","example":"call_01J9Z8QK2M4N6P8R0T2V4X6Z8A"},"description":"Identificador público de la llamada (`call_<ULID>`). Se acepta también el UUID antiguo de la llamada, por compatibilidad."},{"name":"strict","in":"query","schema":{"type":"string","enum":["true"]},"description":"`true` para rechazar con `409` si la llamada ya tiene a otra persona asignada, en lugar de reemplazarla."},{"name":"Idempotency-Key","in":"header","schema":{"type":"string"},"description":"Hace el reintento seguro: la repetición devuelve el resultado de la primera llamada."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkCallMembersRequest"},"example":{"member_ids":["mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A"],"reason":"Reclamación","source":"api"}}}},"responses":{"200":{"description":"Personas vinculadas","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkCallMembersResponse"},"example":{"call_id":"call_01J9Z8QK2M4N6P8R0T2V4X6Z8A","members":[{"id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","full_name":"Marta Gómez","email":"marta@ejemplo.com"}],"member":{"id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","full_name":"Marta Gómez","email":"marta@ejemplo.com"},"previous_member_id":null,"linked_at":"2026-07-26T04:10:00.000Z","linked_by":{"type":"api_token","id":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","label":null}}}}},"400":{"description":"`invalid_json`, `invalid_id_format` (con `field` y, si aplica, `invalid_value`) o `too_many_members` (el máximo es 5).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"too_many_members","max":5}}}},"401":{"description":"`unauthenticated` — falta la credencial o no es válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"404":{"description":"`call_not_found` (no hay esa llamada en tu tenant), `member_not_found` o `no_organization`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"call_not_found"}}}},"409":{"description":"Solo con `?strict=true`: `already_linked`. El cuerpo trae `current_member_id`, quien la tiene asignada ahora mismo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"already_linked","member_id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","current_member_id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8B"}}}},"410":{"description":"`target_not_active` — esa persona existe pero no está activa (invitada o suspendida). El cuerpo trae `member_status`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"target_not_active","member_id":"mem_01J9Z8…","member_status":"invited"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalErrorLegacy"}}},"delete":{"tags":["Llamadas"],"summary":"Quitar la asignación de una llamada","description":"Deja la llamada sin nadie asignado. Queda anotado en la auditoría quién la tenía antes.\n\n**Quita a todos**: si había varias personas vinculadas, las desvincula todas.","operationId":"unlinkCallMember","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string","example":"call_01J9Z8QK2M4N6P8R0T2V4X6Z8A"},"description":"Identificador público de la llamada (`call_<ULID>`), o su UUID antiguo."},{"name":"Idempotency-Key","in":"header","schema":{"type":"string"},"description":"Hace el reintento seguro."}],"responses":{"200":{"description":"Asignación retirada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlinkCallMemberResponse"},"example":{"call_id":"call_01J9Z8QK2M4N6P8R0T2V4X6Z8A","unlinked_member_id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","linked_at":"2026-07-26T04:10:00.000Z"}}}},"400":{"description":"`invalid_id_format` — el `call_id` no tiene una forma aceptable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"401":{"description":"`unauthenticated` — falta la credencial o no es válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"404":{"description":"`call_not_found` o `no_organization`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalErrorLegacy"}}}},"/api/v1/whatsapp/send":{"post":{"tags":["WhatsApp"],"summary":"Enviar un mensaje de WhatsApp","description":"Envía texto (dentro de la ventana de 24h) o una plantilla aprobada (en cualquier momento). Fuera de la ventana de 24h, el texto libre se rechaza con `422 outside_24h_window` o se acepta pero acaba con `status: failed`; usa una plantilla.","operationId":"sendWhatsAppMessage","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"},"examples":{"texto":{"summary":"Texto libre","value":{"to":"+34600111222","type":"text","text":"Hola desde AMAI Voice"}},"plantilla":{"summary":"Plantilla con variables","value":{"to":"+34600111222","type":"template","template_name":"recordatorio_reunion","template_language":"es","template_components":[{"type":"body","parameters":[{"type":"text","text":"Juan"},{"type":"text","text":"15 de julio a las 14:30"}]}]}}}}}},"responses":{"200":{"description":"Mensaje aceptado por Meta","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"},"example":{"success":true,"message_id":"<connId>:wamid.HBg..."}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientScope"},"404":{"description":"`no_organization` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Fuera de la ventana de 24 horas — envía una plantilla aprobada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"outside_24h_window","message":"No se puede enviar texto libre fuera de la ventana de 24h. Envía una plantilla aprobada.","meta_code":131047}}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"description":"`internal_error` — fallo al persistir el mensaje tras aceptarlo Meta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Error de Meta (upstream)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"meta_error","message":"…","meta_code":131026}}}}}}}},"/api/v1/whatsapp/messages":{"get":{"tags":["WhatsApp"],"summary":"Listar mensajes (recibir por polling)","description":"Devuelve mensajes entrantes y salientes del tenant, del más reciente al más antiguo. Para recepción en tiempo real usa el webhook `whatsapp.message.received` en su lugar.","operationId":"listWhatsAppMessages","parameters":[{"name":"contact","in":"query","schema":{"type":"string"},"description":"Filtra por número E.164."},{"name":"direction","in":"query","schema":{"type":"string","enum":["inbound","outbound"]},"description":"Filtra por dirección."},{"name":"connection_id","in":"query","schema":{"type":"string"},"description":"Filtra por conexión."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Página de mensajes","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppMessage"}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"404":{"description":"`No organization found` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalErrorLegacy"}}}},"/api/v1/whatsapp/templates":{"get":{"tags":["WhatsApp"],"summary":"Listar plantillas aprobadas","description":"Lista las plantillas del tenant para saber qué `template_name` usar en el envío. Las plantillas se crean y aprueban en Meta WhatsApp Manager.","operationId":"listWhatsAppTemplates","parameters":[{"name":"status","in":"query","schema":{"type":"string","example":"APPROVED"},"description":"Filtra por estado de Meta (p. ej. APPROVED)."}],"responses":{"200":{"description":"Plantillas del tenant","content":{"application/json":{"schema":{"type":"object","properties":{"templates":{"type":"array","items":{"$ref":"#/components/schemas/Template"}}}},"example":{"templates":[{"name":"recordatorio_reunion","status":"APPROVED","category":"UTILITY","language":"es","variables":2,"body_text":"Hola {{1}}, te recordamos tu reunion con AMAI programada para {{2}}."}]}}}},"400":{"description":"`no_active_connection` — el tenant no tiene ninguna conexión de WhatsApp activa. Conéctala en el panel antes de listar plantillas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientScope"},"404":{"description":"`no_organization` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"description":"`internal_error` — fallo al resolver la conexión o al leer la respuesta de Meta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Error de Meta","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/contacts/identify":{"post":{"tags":["Contactos"],"summary":"Identificar un contacto","description":"Identifica un contacto del tenant por teléfono, email o nombre. Útil para saber quién llama o escribe.","operationId":"identifyContact","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"phone":{"type":"string","description":"Número (E.164 o local, se normaliza)."},"email":{"type":"string"},"name":{"type":"string","description":"Coincidencia parcial contra nombre/empresa."}}},"example":{"phone":"+34600111222"}}}},"responses":{"200":{"description":"Resultado de la búsqueda","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["found","not_found","multiple_matches"]},"contact":{"type":["object","null"],"description":"Datos enmascarados del contacto."},"match_count":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalErrorLegacy"}}}},"/api/v1/members":{"get":{"tags":["Contactos"],"summary":"Listar miembros del equipo","description":"Busca los miembros del tenant por nombre o email, con su presencia y su alcance de acceso.\n\n**Búsqueda tolerante a erratas:** si la búsqueda exacta no encuentra nada y el texto tiene al menos unos pocos caracteres, se reintenta por parecido y la respuesta llega con `meta.fuzzy_match: true` y un `similarity_score` en cada miembro. Está pensado para que un agente de voz pueda preguntar «¿te refieres a Susana González?» en vez de fallar en silencio: **no des por hecho que un resultado con `fuzzy_match` es la persona correcta.**\n\nLa búsqueda ignora los acentos.\n\n**Devuelve datos personales** (nombre y email de cada miembro).","operationId":"listMembers","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Búsqueda por nombre o email. Sin acentos y sin distinguir mayúsculas."},{"name":"status","in":"query","schema":{"type":"string","enum":["active","invited","suspended"],"default":"active"},"description":"Cualquier otro valor devuelve `400 INVALID_FILTER`."},{"name":"online","in":"query","schema":{"type":"string","enum":["true"]},"description":"`true` para quedarte solo con quienes están conectados ahora mismo."},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"per_page","in":"query","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Página de miembros con los agregados del equipo","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MembersResponse"},"example":{"members":[{"id":"0f2b1a64-0000-0000-0000-1c2d3e4f5a6b","public_id":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","external_id":null,"email":"marta@ejemplo.com","full_name":"Marta Gómez","role":"operator","role_label":"Operador","department":null,"custom_role":null,"status":"active","presence":{"online_now":true,"last_seen_at":"2026-07-26T04:08:11Z","last_login_at":"2026-07-26T08:00:00Z"},"access":{"can_view_all_calls":true,"allowed_queues":null,"allowed_phone_number_ids":null},"created_at":"2026-05-02T09:14:00Z"}],"meta":{"total":1,"page":1,"per_page":25,"total_pages":1,"online_count":1,"active_count":4,"fuzzy_match":false}}}}},"400":{"description":"`INVALID_FILTER` — el `status` pedido no es uno de los tres válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"INVALID_FILTER","field":"status","allowed":["active","invited","suspended"]}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"404":{"description":"`No organization found` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalErrorLegacy"},"503":{"description":"`search_timeout` — la búsqueda tardó demasiado. Es reintentable, y a diferencia del resto de errores de esta operación usa el sobre `{error:{code,message}}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"search_timeout","message":"Member search timed out. Please retry."}}}}}}}},"/api/v1/team/mention-list":{"get":{"tags":["Contactos"],"summary":"Listado de personas mencionables del equipo","description":"Devuelve las personas del tenant que se pueden mencionar en una nota o un recado, pensado para poblar un autocompletado de `@menciones`.\n\n**Devuelve datos personales.** Cada elemento lleva el nombre y el **email** del miembro, resueltos contra el directorio de autenticación. Trátalo como dato personal: es material sujeto a RGPD, no lo caches en un cliente público ni lo indexes.","operationId":"getMentionList","responses":{"200":{"description":"Personas mencionables del tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MentionListResponse"},"example":{"members":[{"user_id":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","display_name":"Marta Gómez","email":"marta@ejemplo.com","role":"owner","avatar_initials":"MG"}]}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"$ref":"#/components/responses/InsufficientScopeLegacy"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"}}}},"/api/v1/calendar/check":{"get":{"tags":["Utilidades"],"summary":"Comprobar día/hora laborable","description":"Indica si una fecha (y hora opcional) es laborable para el tenant, con un `reason` estable legible por máquina. Pensado para la lógica de horarios de un agente.\n\n**No existe ningún campo `open`.** Lo que se responde es `is_working_day` para el día y, solo si mandas `time`, `is_open_at_time` para ese momento concreto. Son cosas distintas: un día laborable con la hora fuera del horario da `is_working_day: true` e `is_open_at_time: false`.\n\nSi el calendario no está configurado o está desactivado, responde `200` con `reason: \"not_configured\"` y `is_working_day: false` — no un error.","operationId":"calendarCheck","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","format":"date","example":"2026-07-23"},"description":"`YYYY-MM-DD`."},{"name":"time","in":"query","schema":{"type":"string","example":"10:30"},"description":"`HH:mm` en 24 h (opcional). Si la mandas, la respuesta añade `time` e `is_open_at_time`."},{"name":"company_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"**No sirve para consultar otro tenant.** Solo se acepta si coincide exactamente con el tenant de la credencial; si no, responde `403`. La tenencia sale siempre de la clave."}],"responses":{"200":{"description":"Resolución del día, y del momento si se pidió `time`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarCheckResponse"},"example":{"company_id":"64b7a148-0000-0000-0000-be96196bcf79","date":"2026-07-23","timezone":"Europe/Madrid","is_working_day":true,"is_holiday":false,"reason":"regular_schedule","schedule":{"opens_at":"09:00","closes_at":"18:00","break_starts_at":"14:00","break_ends_at":"15:00"},"applied_override":null,"holiday":null,"time":"10:30","is_open_at_time":true}}}},"400":{"description":"`invalid_date` (no es `YYYY-MM-DD`) o `invalid_time` (no es `HH:mm`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"invalid_date","message":"date must be YYYY-MM-DD"}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"description":"`insufficient_scope` (la clave no tiene `calendar:read`) o `forbidden` (el `company_id` pedido no es el de la clave).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"404":{"description":"`No organization found` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"}}}},"/api/v1/calendar":{"get":{"tags":["Utilidades"],"summary":"Calendario laboral de un rango de fechas","description":"Resuelve, día a día, el calendario laboral del tenant en un rango: horario semanal, festivos aplicables, anulaciones manuales y el motivo estable de cada decisión.\n\nEs la versión de rango de `GET /api/v1/calendar/check`. Úsala para pintar un calendario o precargar la semana en un agente en lugar de hacer siete peticiones.\n\n**Rango máximo: 366 días.**","operationId":"getCalendar","parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date","example":"2026-07-01"},"description":"Primer día del rango, `YYYY-MM-DD`. Inclusivo."},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date","example":"2026-07-31"},"description":"Último día del rango, `YYYY-MM-DD`. Inclusivo. Como mucho 366 días después de `from`."},{"name":"company_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"**No sirve para consultar otro tenant.** Solo se acepta si coincide exactamente con el tenant de la credencial; si no, responde `403`. La tenencia sale siempre de la clave."}],"responses":{"200":{"description":"El rango resuelto, con el horario semanal y la procedencia de los festivos","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarRangeResponse"}}}},"400":{"description":"`invalid_range` (`from`/`to` no son `YYYY-MM-DD`, o `to` es anterior a `from`) o `range_too_large` (más de 366 días).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"range_too_large","message":"Maximum 366 days per request"}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"description":"`insufficient_scope` (la clave no tiene `calendar:read`) o `forbidden` (el `company_id` pedido no es el de la clave).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"404":{"description":"`No organization found` — solo alcanzable autenticando por sesión sin tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"}}}},"/api/v1/email/send":{"post":{"tags":["Utilidades"],"summary":"Enviar un email desde el tenant","description":"Envía un correo con la identidad del tenant. Hace falta `subject` y al menos uno de `html` o `text`.\n\n**Tiene presupuesto: 60 envíos por minuto y clave.** Aun así, el límite técnico no es un permiso: un bucle descontrolado quema la reputación de envío de tu dominio mucho antes de acercarse a esa cifra.\n\n**Quién aparece como remitente** lo decide `from` y `allow_amai_fallback` — léelos antes de usar esto en un producto con tu propia marca.","operationId":"sendEmail","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendEmailRequest"},"example":{"to":"cliente@ejemplo.com","subject":"Tu cita de mañana","text":"Te esperamos a las 10:00."}}}},"responses":{"200":{"description":"Aceptado por el proveedor de correo. **No garantiza la entrega**: mira `used_fallback` para saber por qué infraestructura salió.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendEmailResponse"},"example":{"success":true,"message_id":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","provider_message_id":"<20260726041000.1@mail.ejemplo.com>","used_fallback":false}}}},"400":{"description":"`INVALID_JSON`, `INVALID_RECIPIENT` (el `to` no es una dirección válida) o `MISSING_FIELDS` (falta `subject`, o faltan a la vez `html` y `text`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"INVALID_RECIPIENT"}}}},"401":{"$ref":"#/components/responses/UnauthorizedLegacy"},"403":{"description":"`INSUFFICIENT_SCOPE` (la clave no tiene `email:send`), `FROM_ADDRESS_NOT_REGISTERED` (el `from` que pediste no es un buzón dado de alta en tu tenant) o **`RECIPIENT_SUPPRESSED`** (el destinatario pidió no recibir correo de esta cuenta). En este último caso el cuerpo trae `reason` con la fuente de la negativa: `contact_do_not_email` (baja por el enlace de cancelación), `contact_opt_out`, `suppression_entry` o `exclusion_list` (lo dijo en una llamada). No se reintenta: hay que levantar la supresión desde el panel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"RECIPIENT_SUPPRESSED","reason":"contact_do_not_email","message":"…"}}}},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"502":{"description":"El proveedor de correo rechazó el envío. El cuerpo trae el error del proveedor y, si llegó a crearse, el `message_id` con el que consultarlo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"PROVIDER_ERROR","message":"…"}}}},"503":{"description":"`SUPPRESSION_UNVERIFIABLE` — no se pudo consultar la lista de supresión. **El correo NO se ha enviado**: ante la duda no se escribe a nadie. Es un fallo temporal nuestro; reintenta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"SUPPRESSION_UNVERIFIABLE","message":"…"}}}}}}},"/api/v1/agents":{"get":{"tags":["Agentes"],"summary":"Listar los agentes de voz del tenant","description":"Devuelve los agentes de la cuenta dueña de la API key, del más reciente al más antiguo.\n\nEs la operación que faltaba para poder usar `PATCH /api/v1/agents/{agent_id}`: hasta ahora había que conocer el UUID de antemano, sacándolo del panel a mano.\n\n**Aislamiento:** el tenant se deriva SIEMPRE de la clave autenticada. No hay ningún parámetro para consultar otro tenant.\n\n**Lo que no se publica:** el token del widget del agente (que es una credencial portadora con la que se puede colocar una llamada), el prompt compilado y el estado interno del ciclo de vida.","operationId":"listAgents","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["draft","active","paused","restricted"]},"description":"Filtra por estado. Los agentes borrados nunca aparecen."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de agentes","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","name","industry","status","provisioned","usage"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Recepción Madrid"},"description":{"type":["string","null"]},"industry":{"type":"string","example":"healthcare"},"language":{"type":["string","null"],"example":"es"},"voice":{"type":"object","properties":{"id":{"type":["string","null"]},"name":{"type":["string","null"],"example":"Ana"}}},"first_message":{"type":["string","null"],"description":"El saludo con el que abre la llamada."},"system_prompt":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","restricted"],"description":"Los agentes en estado `deleted` no se listan ni se sirven."},"provisioned":{"type":"boolean","description":"`true` cuando el agente tiene asistente creado y puede atender. Se publica el booleano y no el identificador del proveedor, que es una referencia a nuestra cuenta."},"usage":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"total_minutes":{"type":"number","example":618.5}}},"created_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"3f1c9a2e-77b4-4e51-9d0a-5c2b8e6f1a33","name":"Recepción Madrid","description":"Atiende la centralita fuera de horario","industry":"healthcare","language":"es","voice":{"id":"elevenlabs_ana","name":"Ana"},"first_message":"Hola, soy Ana de Clínica Ejemplo. ¿En qué puedo ayudarte?","system_prompt":"Eres una recepcionista amable y resolutiva…","status":"active","provisioned":true,"usage":{"total_calls":412,"total_minutes":618.5},"created_at":"2026-03-02T09:14:00Z","updated_at":"2026-07-20T11:02:31Z"}],"pagination":{"limit":50,"offset":0,"total":3,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/agents/templates":{"get":{"tags":["Agentes"],"summary":"Listar el catálogo de plantillas de agente","description":"Las plantillas activas de las que se puede partir para crear un agente, en el mismo orden que el panel (la predeterminada primero, luego alfabético).\n\n**Este catálogo es de plataforma, no del tenant**: sus filas son idénticas para todas las cuentas y la tabla no tiene columna de tenant, así que aquí no hay nada que aislar. Lo que sí se comprueba es lo mismo que en el resto: clave válida, scope `agents:read` y capacidad de tenant `view_ai_agents`. Que el dato sea común no lo hace público.","operationId":"listAgentTemplates","responses":{"200":{"description":"Catálogo de plantillas","content":{"application/json":{"schema":{"type":"object","required":["data","total"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","slug","name","is_default"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","example":"recepcionista"},"name":{"type":"string","example":"Recepcionista"},"description":{"type":["string","null"]},"icon":{"type":["string","null"]},"category":{"type":["string","null"],"example":"atencion"},"is_default":{"type":"boolean"}}}},"total":{"type":"integer","example":7}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/agents/{agent_id}":{"get":{"tags":["Agentes"],"summary":"Ver un agente","description":"Devuelve la MISMA forma que el listado, para un agente concreto.\n\n**Leer no autoriza a escribir:** esta operación exige el scope `agents:read` y la capacidad de tenant `view_ai_agents`; el `PATCH` de la misma ruta exige `agents:write`, `edit_ai_agents` y además el flag de escritura de la instancia.\n\nUn agente borrado devuelve 404: para el consumidor de la API ya no existe.","operationId":"getAgent","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"El agente","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","name","industry","status","provisioned","usage"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Recepción Madrid"},"description":{"type":["string","null"]},"industry":{"type":"string","example":"healthcare"},"language":{"type":["string","null"],"example":"es"},"voice":{"type":"object","properties":{"id":{"type":["string","null"]},"name":{"type":["string","null"],"example":"Ana"}}},"first_message":{"type":["string","null"],"description":"El saludo con el que abre la llamada."},"system_prompt":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","restricted"],"description":"Los agentes en estado `deleted` no se listan ni se sirven."},"provisioned":{"type":"boolean","description":"`true` cuando el agente tiene asistente creado y puede atender. Se publica el booleano y no el identificador del proveedor, que es una referencia a nuestra cuenta."},"usage":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"total_minutes":{"type":"number","example":618.5}}},"created_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Agentes"],"summary":"Actualizar el contenido de un agente","description":"Actualiza nombre, descripción, saludo, prompt, idioma y voz, y propaga el cambio al asistente de VAPI. La sincronización con VAPI es best-effort: si falla, el cambio en base de datos se mantiene y la respuesta lo indica en `vapi_synced` / `vapi_error`.\n\nNO expone `status`: activar un agente no es un cambio de estado, sino un flujo con guardas propias (solo borradores, creación del asistente en VAPI) que vive en el panel. Los agentes en estado `deleted` o `restricted` no son editables por esta vía. Requiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"updateAgent","parameters":[{"name":"agent_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentRequest"},"example":{"first_message":"Hola, soy Ana de Acme. ¿En qué puedo ayudarte?","system_prompt":"Eres una recepcionista amable y resolutiva…"}}}},"responses":{"200":{"description":"Agente actualizado","content":{"application/json":{"schema":{"type":"object","properties":{"agent":{"$ref":"#/components/schemas/Agent"},"vapi_synced":{"type":["boolean","null"],"description":"true/false si se intentó sincronizar con VAPI; null si no hacía falta."},"vapi_error":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientWriteScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/api/v1/billing/balance":{"get":{"tags":["Facturación"],"summary":"Consultar el saldo del monedero","description":"El saldo actual de la cuenta dueña de la API key.\n\n**Lee `prepaid` antes de comparar el saldo con un umbral.** El número no significa lo mismo en todas las cuentas: una cuenta exenta o postpago puede tener 0 y estar al corriente, porque no se le descuenta de un monedero — se le factura. Una alerta que ignore este campo saltará todos los días precisamente para los clientes que más pagan.\n\nEsta operación no acepta parámetros.","operationId":"getBalance","responses":{"200":{"description":"Saldo y modo de facturación","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["currency","balance_usd","prepaid","billing_mode"],"properties":{"currency":{"type":"string","enum":["USD"]},"balance_usd":{"type":"number","example":42.3175},"prepaid":{"type":"boolean","description":"`true` solo cuando el saldo es realmente el límite de gasto. En postpago o exento el número existe pero no frena nada."},"billing_mode":{"type":"string","example":"prepaid","description":"`prepaid`, `postpaid` o `exempt`."},"plan_slug":{"type":["string","null"],"example":"starter"}}}}},"example":{"data":{"currency":"USD","balance_usd":42.3175,"prepaid":true,"billing_mode":"prepaid","plan_slug":"starter"}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/billing/usage":{"get":{"tags":["Facturación"],"summary":"Consumo del periodo","description":"El gasto de la cuenta en el rango pedido, separado en llamadas y cuotas de números.\n\n**Los números de `calls` son los MISMOS que devuelve `GET /api/v1/calls` para el mismo rango**, y lo son por construcción: las dos operaciones comparten el servicio que pliega las dos piernas de una llamada de agente y une las llamadas que van por el troncal de VAPI. Si cuadras una, cuadras la otra.\n\n**Las cuotas de números van aparte a propósito.** Un número se cobra por meses, no por minutos, y sumarlo dentro del coste de llamadas rompería el coste por minuto, que es la cifra que se compara contra otro operador.\n\nEl alcance incluye las llamadas de subcuentas que **paga esta cuenta**.","operationId":"getUsage","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."}],"responses":{"200":{"description":"Consumo del periodo","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["period","currency","calls","numbers","total_usd"],"properties":{"period":{"type":"object","properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}},"currency":{"type":"string","enum":["USD"]},"calls":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"connected_calls":{"type":"integer","example":388},"failed_calls":{"type":"integer","example":24},"total_billed_seconds":{"type":"integer","example":24870},"total_billed_minutes":{"type":"number","example":414.5},"telephony_cost_usd":{"type":"number","example":8.1},"ai_cost_usd":{"type":"number","example":4.3402},"total_cost_usd":{"type":"number","example":12.4402},"average_cost_per_minute_usd":{"type":["number","null"],"example":0.03,"description":"`null`, nunca `0`, si no hubo minutos facturados."}}},"numbers":{"type":"object","properties":{"monthly_fees_usd":{"type":"number","example":6.4,"description":"Cuotas de número cobradas en el rango, leídas del libro mayor (lo cobrado de verdad, no una proyección), en positivo."}}},"total_usd":{"type":"number","example":18.8402}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/billing/invoices":{"get":{"tags":["Facturación"],"summary":"Listar las facturas emitidas","description":"Las facturas de la pasarela de pago, con sus enlaces de PDF y de portal.\n\n**Una cuenta sin facturas devuelve una lista vacía y un `200`, no un 404.** La mayoría de cuentas son de prepago puro y no tienen ninguna: eso no es un error, y un 404 haría pensar que el endpoint no existe.\n\nAquí solo hay **facturas de verdad**. El extracto del mes en curso que el panel muestra junto a ellas no es una factura —no tiene número, ni fecha de emisión, ni PDF, y cambia entre dos peticiones del mismo día—, así que no se publica como tal: el consumo del periodo está en `GET /api/v1/billing/usage`.\n\n`offset` no aplica: la pasarela pagina por cursor y esta operación devuelve las `limit` más recientes.","operationId":"listInvoices","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Cuántas facturas devolver, de la más reciente hacia atrás."}],"responses":{"200":{"description":"Facturas","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","currency","amount_due","amount_paid"],"properties":{"id":{"type":"string","example":"in_1QxYz0000000000"},"number":{"type":["string","null"],"example":"A1B2C3-0007"},"status":{"type":"string","example":"paid"},"currency":{"type":"string","example":"USD"},"amount_due":{"type":"number","example":29,"description":"En unidades de la moneda, no en céntimos."},"amount_paid":{"type":"number","example":29},"issued_at":{"type":["string","null"],"format":"date-time"},"period_start":{"type":["string","null"],"format":"date-time"},"period_end":{"type":["string","null"],"format":"date-time"},"pdf_url":{"type":["string","null"]},"hosted_url":{"type":["string","null"]}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/billing/transactions":{"get":{"tags":["Facturación"],"summary":"Listar los movimientos del monedero","description":"El libro mayor de la cuenta: recargas, descuentos por llamada y cuotas de números.\n\n**El signo de `amount_usd` es el del libro**: negativo un cargo, positivo un abono. La columna es sumable tal cual y da la variación del saldo. No se publica en valor absoluto a propósito — quien la sumara obtendría el doble del gasto.\n\n**`scope` importa en una agencia.** Un movimiento `delegated` es consumo de una subcuenta que paga esta cuenta. Se incluyen porque son los que le vacían el saldo: un extracto sin ellos no cuadraría con el propio saldo.\n\n`reference.id` viene a `null` cuando la referencia apunta a un identificador de la pasarela de pago, que es de nuestra cuenta. El `type` se declara igualmente, para que se sepa que hay una referencia y que no se va a dar.","operationId":"listTransactions","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"type","in":"query","schema":{"type":"string","example":"call_deduction"},"description":"Filtra por tipo de movimiento (`call_deduction`, `did_monthly_fee`, `recharge`, `refund`, `adjustment`…). No se valida contra una lista cerrada: el catálogo crece con el producto, y un enum aquí rechazaría tipos que la plataforma ya escribe."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de movimientos","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","type","amount_usd","scope","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":["string","null"],"example":"call_deduction"},"amount_usd":{"type":"number","example":-0.0582,"description":"Negativo = cargo, positivo = abono."},"balance_after_usd":{"type":"number","example":42.3175},"description":{"type":["string","null"]},"reference":{"type":"object","properties":{"type":{"type":["string","null"],"example":"call_log"},"id":{"type":["string","null"]}}},"scope":{"type":"string","enum":["direct","delegated"],"description":"`direct` = consumo propio. `delegated` = de una subcuenta que paga esta cuenta."},"created_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/numbers":{"get":{"tags":["Telefonía"],"summary":"Listar los números del tenant","description":"El inventario de números de la cuenta, con su configuración y **su precio**.\n\n`pricing.monthly_usd` y `pricing.setup_usd` son lo que paga el cliente. El coste que nosotros pagamos al operador vive en otras dos columnas de la misma tabla y no se publica por ninguna vía: la diferencia entre ambos pares es el margen.\n\n**El alcance incluye los números de subcuentas que factura esta cuenta** (`scope: \"delegated\"`), porque es el inventario que le toca cuadrar.\n\nNo se publican los identificadores del operador ni la configuración de nuestra centralita: describen nuestra cadena de suministro, no el número del cliente.","operationId":"listNumbers","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["available","assigned","reserved","porting"]},"description":"Filtra por estado del número."},{"name":"country_code","in":"query","schema":{"type":"string","example":"34"},"description":"Filtra por prefijo de país."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de números","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","number","country_code","type","status","pricing","scope"],"properties":{"id":{"type":"string","format":"uuid"},"number":{"type":"string","example":"+34912918750"},"country_code":{"type":"string","example":"34"},"country_name":{"type":"string","example":"Spain"},"city":{"type":["string","null"],"example":"Madrid"},"type":{"type":"string","example":"local","description":"`local`, `mobile` o `tollfree`."},"status":{"type":"string","enum":["available","assigned","reserved","porting"]},"label":{"type":["string","null"],"example":"Oficina Madrid"},"capabilities":{"type":"array","items":{"type":"string"},"example":["voice"]},"webphone_enabled":{"type":"boolean"},"inbound_agent_id":{"type":["string","null"],"format":"uuid"},"outbound_agent_id":{"type":["string","null"],"format":"uuid"},"pricing":{"type":"object","description":"El precio que paga el CLIENTE. Nunca nuestro coste.","properties":{"currency":{"type":"string","enum":["USD"]},"monthly_usd":{"type":"number","example":2.4},"setup_usd":{"type":"number","example":2.4}}},"scope":{"type":"string","enum":["direct","delegated"],"description":"`direct` = de esta cuenta. `delegated` = de una subcuenta que paga esta cuenta."},"assigned_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/rates":{"get":{"tags":["Telefonía"],"summary":"Consultar las tarifas de salida de la cuenta","description":"El precio por minuto **de esta cuenta** para cada destino, con su recargo ya aplicado.\n\n**Lee `has_negotiated_rates` antes de usar estos importes para facturar nada.** Cuando vale `true`, la cuenta (o alguien de quien hereda) tiene una tarifa negociada por ruta, y lo que se cobra de verdad puede ser distinto de lo que devuelve esta operación, que publica la tarifa **estándar**. Enseñar con seguridad un precio equivocado justo a quien negoció el suyo es peor que no enseñarlo, así que se dice.\n\nSin `continent` ni `search` devuelve el tarifario completo, que son varios miles de filas: para uso interactivo conviene filtrar.","operationId":"listRates","parameters":[{"name":"continent","in":"query","schema":{"type":"string","enum":["LATAM","NORTEAMERICA","EUROPA","ASIA","AFRICA","OCEANIA","SATELITAL","OTROS"]},"description":"Limita el tarifario a un continente."},{"name":"search","in":"query","schema":{"type":"string","example":"colombia"},"description":"Busca por nombre de destino en todos los continentes. Tiene prioridad sobre `continent`."}],"responses":{"200":{"description":"Tarifas de la cuenta","content":{"application/json":{"schema":{"type":"object","required":["data","total","currency","has_negotiated_rates"],"properties":{"data":{"type":"array","items":{"type":"object","required":["continent","destination","line_type","rate_per_minute_usd"],"properties":{"continent":{"type":"string","example":"LATAM"},"destination":{"type":"string","example":"Colombia Mobile"},"line_type":{"type":"string","enum":["fixed","mobile"]},"rate_per_minute_usd":{"type":"number","example":0.012,"description":"Precio por minuto para esta cuenta, recargo incluido."}}}},"total":{"type":"integer","example":1834},"currency":{"type":"string","enum":["USD"]},"has_negotiated_rates":{"type":"boolean","description":"`true` si existe una tarifa negociada en esta cuenta o en alguno de sus padres. Entonces los importes de `data` son la tarifa ESTÁNDAR y no necesariamente lo facturado. Ante un error de lectura se devuelve `true`: «no lo sé» se resuelve avisando, no callando."}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/rates/countries":{"get":{"tags":["Telefonía"],"summary":"Listar el catálogo de destinos que se pueden llamar","description":"El índice de `GET /api/v1/rates`: qué continentes hay, qué destinos tiene cada uno y qué tipos de línea. Sirve para saber qué valores admite `?continent=` sin descargar el tarifario entero.\n\n**Sin precios, a propósito**: están en `/api/v1/rates` con el recargo de la cuenta ya aplicado, y repetirlos aquí obligaría a mantener dos caminos de cálculo.\n\n**No es el catálogo de países donde COMPRAR un número.** Ese depende de un catálogo de operador que es plano de coste, y va con la compra en una fase posterior.\n\nEsta operación no acepta parámetros.","operationId":"listRateCountries","responses":{"200":{"description":"Catálogo de destinos por continente","content":{"application/json":{"schema":{"type":"object","required":["data","total"],"properties":{"data":{"type":"array","items":{"type":"object","required":["continent","destinations","total_destinations","line_types"],"properties":{"continent":{"type":"string","example":"EUROPA"},"destinations":{"type":"array","items":{"type":"string"},"example":["Spain Fixed","Spain Mobile"]},"total_destinations":{"type":"integer","example":214},"line_types":{"type":"array","items":{"type":"string","enum":["fixed","mobile"]}}}}},"total":{"type":"integer","example":1834}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/analytics/dashboard":{"get":{"tags":["Analítica"],"summary":"Indicadores del periodo y su variación","description":"Lo que el panel pinta en su portada, en una llamada: llamadas, conexión, minutos, coste y agentes, más **la variación respecto al periodo anterior de la misma duración**.\n\n**`change_pct` puede venir a `null`, y no es un fallo**: cuando el periodo anterior no tuvo actividad, no hay variación que calcular. Devolver `100` en ese caso sería arbitrario — pasar de 0 a 1 llamada no es «un 100 % más».\n\nLos números salen del mismo servicio que `GET /api/v1/calls`, así que cuadran con él. El coste es el del CLIENTE: no hay ningún campo con nuestro coste de proveedor ni con el margen.","operationId":"getAnalyticsDashboard","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."}],"responses":{"200":{"description":"Indicadores del periodo","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["period","previous_period","currency","calls","cost","agents","change_pct"],"properties":{"period":{"type":"object","properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}},"previous_period":{"type":"object","description":"Misma duración exacta, terminando donde empieza el actual. No es el mes natural anterior.","properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}},"currency":{"type":"string","enum":["USD"]},"calls":{"type":"object","properties":{"total":{"type":"integer","example":412},"connected":{"type":"integer","example":388},"failed":{"type":"integer","example":24},"connect_rate_pct":{"type":["number","null"],"example":94.2},"billed_minutes":{"type":"number","example":414.5}}},"cost":{"type":"object","description":"Importes para el CLIENTE, en USD.","properties":{"telephony_usd":{"type":"number","example":8.1},"ai_usd":{"type":"number","example":4.3402},"total_usd":{"type":"number","example":12.4402},"average_per_minute_usd":{"type":["number","null"],"example":0.03}}},"agents":{"type":"object","properties":{"total":{"type":"integer","example":3},"active":{"type":"integer","example":2}}},"change_pct":{"type":"object","description":"`null` en cada campo cuyo periodo anterior fue cero.","properties":{"total_calls":{"type":["number","null"],"example":12.4},"connected_calls":{"type":["number","null"],"example":9.8},"billed_minutes":{"type":["number","null"],"example":-3.1},"total_cost_usd":{"type":["number","null"],"example":5.2}}}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/analytics/calls":{"get":{"tags":["Analítica"],"summary":"Desglose de llamadas por dirección y resultado","description":"El mismo total del cuadro de mando, abierto por entrantes/salientes y por conectadas/fallidas, con su coste.\n\n**No hay serie temporal por día, y conviene saber por qué antes de buscarla.** Una serie diaria correcta exige un agregado en base de datos que hoy no se puede hacer: el plegado de las dos piernas de una llamada de agente es una comparación fila-con-fila que la capa de datos no sabe expresar, y en este despliegue las funciones de agregado están deshabilitadas. Las dos alternativas se descartaron por escrito: recorrer el histórico una vez por día serían hasta 366 recorridos en una sola petición, y agrupar solo la página devuelta daría una serie construida sobre 50 filas presentada como si fuera el periodo entero.","operationId":"getCallAnalytics","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."}],"responses":{"200":{"description":"Desglose del periodo","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["period","currency","total","by_direction","by_status"],"properties":{"period":{"type":"object","properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}},"currency":{"type":"string","enum":["USD"]},"total":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"connected_calls":{"type":"integer","example":388},"failed_calls":{"type":"integer","example":24},"billed_minutes":{"type":"number","example":414.5},"telephony_cost_usd":{"type":"number","example":8.1},"ai_cost_usd":{"type":"number","example":4.3402},"total_cost_usd":{"type":"number","example":12.4402},"average_cost_per_minute_usd":{"type":["number","null"],"example":0.03,"description":"`null`, nunca `0`, si no hubo minutos facturados."}}},"by_direction":{"type":"object","properties":{"inbound":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"connected_calls":{"type":"integer","example":388},"failed_calls":{"type":"integer","example":24},"billed_minutes":{"type":"number","example":414.5},"telephony_cost_usd":{"type":"number","example":8.1},"ai_cost_usd":{"type":"number","example":4.3402},"total_cost_usd":{"type":"number","example":12.4402},"average_cost_per_minute_usd":{"type":["number","null"],"example":0.03,"description":"`null`, nunca `0`, si no hubo minutos facturados."}}},"outbound":{"type":"object","properties":{"total_calls":{"type":"integer","example":412},"connected_calls":{"type":"integer","example":388},"failed_calls":{"type":"integer","example":24},"billed_minutes":{"type":"number","example":414.5},"telephony_cost_usd":{"type":"number","example":8.1},"ai_cost_usd":{"type":"number","example":4.3402},"total_cost_usd":{"type":"number","example":12.4402},"average_cost_per_minute_usd":{"type":["number","null"],"example":0.03,"description":"`null`, nunca `0`, si no hubo minutos facturados."}}}}},"by_status":{"type":"object","properties":{"connected":{"type":"integer","example":388},"failed":{"type":"integer","example":24}}}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/audit-log":{"get":{"tags":["Auditoría"],"summary":"Leer el registro de actividad de la cuenta","description":"Los eventos de la cuenta como una sola cola, pensada para enchufarse a un SIEM.\n\nUne los dos libros que el panel presenta en pestañas: los eventos de negocio (`source: \"activity\"`) y los de seguridad de la cuenta — alta, acceso, cambio de contraseña — (`source: \"security\"`). Un SIEM quiere una cola, no dos endpoints que correlacionar a mano.\n\n**No se publica el `metadata` del evento.** Es un objeto libre donde cada punto de escritura mete lo que le parece, sin forma declarada ni límite. En esta plataforma esa clase de campo es por donde se han escapado credenciales más de una vez, así que se publican campos con nombre —quién, qué, sobre qué, cuándo, desde dónde— y no el saco entero. Si un evento concreto necesita un dato de ahí, se promociona a campo con nombre y se documenta.\n\n**La IP viene completa**, al contrario que en el panel: una IP truncada no correlaciona, que es lo único que un SIEM hace con ella.\n\n`severity` sigue el mismo criterio en las dos fuentes (`critical` para acceso fallido o cuenta suspendida, `warning` para cambio de contraseña o baja solicitada, `info` para el resto), de modo que una regla de enrutado se comporte igual venga de donde venga.\n\n**Aviso sobre la paginación con `source=all`:** se paginan las dos tablas con el mismo `offset` y se mezclan después, así que una página puede traer menos elementos que `limit`. Usa `has_more` y no el tamaño de `data` como condición de parada.","operationId":"listAuditLog","parameters":[{"name":"source","in":"query","schema":{"type":"string","enum":["activity","security","all"],"default":"all"},"description":"Qué libro leer. Por defecto los dos."},{"name":"action","in":"query","schema":{"type":"string","example":"login_failed"},"description":"Filtra por acción exacta."},{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de eventos, del más reciente al más antiguo","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","source","action","severity","actor","target","occurred_at"],"properties":{"id":{"type":"string","format":"uuid"},"source":{"type":"string","enum":["activity","security"]},"action":{"type":"string","example":"agent.updated"},"category":{"type":["string","null"],"example":"agents"},"severity":{"type":"string","example":"info"},"actor":{"type":"object","properties":{"user_id":{"type":["string","null"],"format":"uuid"},"ip":{"type":["string","null"],"example":"88.12.34.56"},"user_agent":{"type":["string","null"]}}},"target":{"type":"object","properties":{"type":{"type":["string","null"],"example":"agent"},"id":{"type":["string","null"]}}},"occurred_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"9c2f0f1a-1f7b-4f2b-9a11-77a0d1b2c3e4","source":"security","action":"login_failed","category":"auth","severity":"critical","actor":{"user_id":"11111111-1111-4111-8111-111111111111","ip":"88.12.34.56","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"},"target":{"type":"account","id":null},"occurred_at":"2026-07-24T08:11:03Z"}],"pagination":{"limit":50,"offset":0,"total":1284,"has_more":true}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/email/history":{"get":{"tags":["Utilidades"],"summary":"Listar el histórico de email","description":"La otra mitad de `POST /api/v1/email/send`: hasta ahora se podía enviar por API y no comprobar por API si llegó.\n\n**Se publica `body_preview`, no el cuerpo completo.** Un correo puede ser arbitrariamente grande y es contenido de terceros; el resumen más `body_length` bastan para reconocer un mensaje sin arrastrar su contenido a los registros de nadie.\n\n**Los correos de OTP vienen con el resumen redactado por la plataforma**, no por esta operación: el cuerpo llevaría el código en claro y guardarlo anularía el cifrado del propio código. Se ven como enviados, con su estado, y sin el código.\n\n**Diferencia con el panel:** allí un miembro del equipo sin permiso de ver todos los buzones no ve los correos ligados a contactos privados. Ese filtro protege a unos usuarios del tenant de otros, no al tenant de nadie; una API key no es una persona y equivale al propietario de la cuenta, que en el panel los ve todos. Aquí el histórico viene entero.","operationId":"listEmailHistory","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"purpose","in":"query","schema":{"type":"string","example":"notification"},"description":"Filtra por propósito del envío."},{"name":"status","in":"query","schema":{"type":"string","example":"sent"},"description":"Filtra por estado del envío."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página del histórico","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","to","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":["string","null"],"example":"sent"},"purpose":{"type":["string","null"],"example":"notification"},"from":{"type":"object","properties":{"address":{"type":["string","null"]},"name":{"type":["string","null"]}}},"to":{"type":"string","example":"cliente@example.com"},"subject":{"type":["string","null"]},"body_preview":{"type":["string","null"],"description":"Resumen. Redactado por la plataforma cuando es un OTP."},"body_length":{"type":["integer","null"]},"has_attachments":{"type":"boolean"},"provider_message_id":{"type":["string","null"]},"error":{"type":["object","null"],"description":"`null` cuando el envío no falló.","properties":{"code":{"type":["string","null"]},"message":{"type":["string","null"]}}},"contact_id":{"type":["string","null"],"format":"uuid"},"call_id":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"sent_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/sms/history":{"get":{"tags":["Utilidades"],"summary":"Listar el histórico de SMS","description":"Los SMS enviados por la cuenta, con su estado y **su coste**.\n\n**`cost_usd` es el precio del CLIENTE, con su recargo ya aplicado** — el equivalente de la columna de cargo de una llamada, no de nuestro coste de proveedor. Que el campo se llame «cost» no lo convierte en plano de coste: se siguió el valor hasta su cálculo antes de publicarlo. Viene a `null` cuando el envío no llegó a salir.\n\n**El cuerpo sí se publica** (son 160 caracteres, no un documento), salvo en los SMS de OTP: ahí la plataforma persiste un texto de sustitución, porque el cuerpo real llevaría el código en claro. Esta operación no puede filtrarlo ni queriendo — el dato no está en la fila.\n\nNo se publica el volcado crudo de la respuesta del operador.","operationId":"listSmsHistory","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"purpose","in":"query","schema":{"type":"string","example":"notification"},"description":"Filtra por propósito del envío."},{"name":"status","in":"query","schema":{"type":"string","example":"sent"},"description":"Filtra por estado del envío."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página del histórico","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","to","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":["string","null"],"example":"sent"},"purpose":{"type":["string","null"],"example":"notification"},"to":{"type":"string","example":"+34600111222"},"to_type":{"type":["string","null"],"example":"mobile"},"sender_id":{"type":["string","null"],"example":"AMAI"},"body":{"type":["string","null"],"description":"Texto de sustitución en los envíos de OTP."},"body_length":{"type":["integer","null"]},"provider_message_id":{"type":["string","null"]},"error":{"type":["object","null"],"description":"`null` cuando el envío no falló.","properties":{"code":{"type":["string","null"]},"message":{"type":["string","null"]}}},"cost_usd":{"type":["number","null"],"example":0.0412,"description":"Precio para el cliente, recargo incluido. `null` si no se envió."},"contact_id":{"type":["string","null"],"format":"uuid"},"call_id":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"sent_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/knowledge-bases":{"get":{"tags":["Knowledge Base"],"summary":"Listar las bases de conocimiento de la cuenta","description":"El índice que hace usable la búsqueda: sin él habría que sacar los identificadores del panel a mano.\n\n**Mira `status` antes de buscar.** Una base en `empty` o `indexing` responderá sin resultados, y sin ese dato el integrador concluiría que su consulta no encuentra nada cuando lo que pasa es que aún no hay nada indexado.\n\nNo se publica nada de la infraestructura vectorial (nombre de la colección, conexión, mapeo de campos ni identificadores de herramienta del proveedor): es nuestra infraestructura, no el dato del cliente.","operationId":"listKnowledgeBases","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de bases de conocimiento","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","name","status","documents","chunks","external"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Manual de producto"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["empty","indexing","ready","error"],"description":"Solo `ready` responde búsquedas con contenido."},"documents":{"type":"integer","example":24},"chunks":{"type":"integer","example":1893},"external":{"type":"boolean","description":"`true` cuando apunta a una colección vectorial que el cliente ya tenía."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/knowledge-bases/{kb_id}/search":{"post":{"tags":["Knowledge Base"],"summary":"Buscar en una base de conocimiento","description":"Búsqueda semántica sobre una base de la cuenta.\n\n**Es un `POST` con un scope de LECTURA (`kb:read`), y no es una incoherencia.** El scope describe la autoridad que se ejerce, no el verbo. El `POST` está ahí por una razón mecánica: la consulta es texto libre y puede ser un párrafo, y meterla en la query string la dejaría escrita en los registros de todos los proxies del camino. La operación no crea ni modifica nada del cliente, así que no depende del flag de escritura de la instancia.\n\n**Dos cosas que sí consume, dichas por delante:** cada llamada genera un *embedding* contra un proveedor externo (una fracción de céntimo, la misma que el buscador del panel) y deja una fila en el registro de consultas de la propia cuenta. Por eso el presupuesto de 60 peticiones por minuto y clave importa aquí más que en el resto de la superficie: **gestiona el 429**.\n\nUna base de otra cuenta devuelve **404 y no 403**: un 403 confirmaría que el identificador existe.","operationId":"searchKnowledgeBase","parameters":[{"name":"kb_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query"],"properties":{"query":{"type":"string","maxLength":4000,"example":"¿cuál es el plazo de devolución?","description":"Consulta en lenguaje natural. Obligatoria y no vacía."},"top_k":{"type":"integer","default":10,"minimum":1,"maximum":50,"description":"Cuántos fragmentos devolver."}}},"example":{"query":"¿cuál es el plazo de devolución?","top_k":5}}}},"responses":{"200":{"description":"Fragmentos relevantes, del más al menos parecido","content":{"application/json":{"schema":{"type":"object","required":["data","total","latency_ms"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","score","text"],"properties":{"id":{"type":"string"},"score":{"type":"number","example":0.8123},"text":{"type":"string","description":"El fragmento que ha casado."},"context":{"type":"string","description":"El bloque completo del que sale `text`, para dar contexto."},"title":{"type":["string","null"]},"source_url":{"type":["string","null"]}}}},"total":{"type":"integer","example":5},"latency_ms":{"type":"integer","example":412}}}}}},"400":{"description":"Cuerpo o parámetros inválidos. Un `query` vacío se rechaza en vez de gastar un *embedding* para no devolver nada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_json":{"summary":"El cuerpo no es JSON","value":{"error":{"code":"invalid_json","message":"El cuerpo de la petición no es JSON válido."}}},"invalid_body":{"summary":"El cuerpo no es un objeto","value":{"error":{"code":"invalid_body","message":"El cuerpo debe ser un objeto JSON."}}},"invalid_field":{"summary":"Falta `query` o `top_k` está fuera de rango","value":{"error":{"code":"invalid_field","message":"\"query\" es obligatorio y debe ser un texto no vacío."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/subaccounts":{"get":{"tags":["Canal"],"summary":"Listar las subcuentas del árbol","description":"Las cuentas que cuelgan de esta, a cualquier profundidad. El uso previsto es sincronizar la cartera con el CRM del revendedor.\n\n**El alcance no se pide, se deriva.** No hay ningún parámetro de alcance, ni una lista de identificadores, ni un `parent_id`: el conjunto visible sale del árbol de la cuenta autenticada. Un cliente no puede ampliar su propio alcance porque no hay dónde escribirlo.\n\n`direct_child` distingue a los hijos directos de los nietos, para quien necesite quedarse solo con el primer nivel.\n\n**Lo que no se publica:** el recargo que se aplica a cada subcuenta, su plan de tarifas (marcado como exclusivo de administración) y los identificadores de la pasarela de pago. El saldo sí, porque es dato del árbol de quien pregunta y sin él un revendedor no puede ver a qué cliente suyo se le está acabando.\n\n**Solo para cuentas `agency` y `partner`.** Una cuenta de cliente recibe `403 capability_required`.","operationId":"listSubaccounts","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["active","suspended","pending","deleted"]},"description":"Filtra por estado. Sin este parámetro se excluyen las cuentas borradas."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de subcuentas","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","balance_usd","direct_child"],"properties":{"id":{"type":"string","format":"uuid"},"company_name":{"type":["string","null"],"example":"Clínica Ejemplo S.L."},"contact_name":{"type":["string","null"]},"email":{"type":["string","null"]},"role":{"type":["string","null"],"example":"client"},"plan_slug":{"type":["string","null"],"example":"free"},"status":{"type":["string","null"],"example":"active"},"account_kind":{"type":["string","null"],"example":"customer"},"account_profile":{"type":["string","null"],"example":"client_payg"},"billing_mode":{"type":["string","null"],"example":"payg"},"balance_usd":{"type":"number","example":12.4},"direct_child":{"type":"boolean","description":"`true` si cuelga directamente de esta cuenta."},"hierarchy_depth":{"type":["integer","null"],"example":2},"created_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/agency/stats":{"get":{"tags":["Canal"],"summary":"Agregado del canal en el periodo","description":"Cuántas subcuentas, cuántos agentes y qué consumo ha generado el árbol.\n\n**Agrega el ÁRBOL COMPLETO, no solo los hijos directos.** El panel agrega únicamente el primer nivel, así que en una jerarquía de tres niveles se deja fuera a los nietos y sus cifras pueden no coincidir con las de aquí. Queda dicho para que la diferencia no se descubra por sorpresa.\n\n`usage.spend_usd` es lo que **gastaron las subcuentas**, leído del libro mayor — lo cobrado de verdad, no una proyección recalculada — y en positivo. `usage.calls` cuenta movimientos de cargo por llamada, que no es exactamente el número de llamadas plegadas que devuelve la analítica.\n\nNo hay ningún campo de comisión ni de margen: eso pertenece a la relación entre el revendedor y nosotros, no a los datos de su canal.\n\n**Solo para cuentas `agency` y `partner`.**","operationId":"getAgencyStats","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."}],"responses":{"200":{"description":"Agregado del canal","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["period","currency","subaccounts","agents","usage"],"properties":{"period":{"type":"object","properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}},"currency":{"type":"string","enum":["USD"]},"subaccounts":{"type":"object","properties":{"total":{"type":"integer","example":18},"active":{"type":"integer","example":16}}},"agents":{"type":"object","properties":{"total":{"type":"integer","example":31},"active":{"type":"integer","example":24}}},"usage":{"type":"object","properties":{"calls":{"type":"integer","example":4120,"description":"Movimientos de cargo por llamada en el periodo, no llamadas plegadas."},"spend_usd":{"type":"number","example":214.77,"description":"Lo que gastaron las subcuentas, en positivo."}}}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/recados":{"get":{"tags":["Espacio de trabajo"],"summary":"Listar la bandeja de recados","description":"Los avisos que el agente de voz deja tras una llamada: quién llamó, para qué, qué hay que hacer y en qué estado está. El uso previsto es volcarlos al CRM o al sistema de tickets del cliente.\n\n**`source` dice de dónde salió la página, y conviene mirarlo.** El producto tiene dos almacenes: las cuentas con base de datos propia guardan sus recados allí (`source: \"tenant_database\"`) y el resto en la nuestra (`source: \"amai\"`). Esta operación enruta a la que corresponda, igual que el panel.\n\n**Un `503` aquí no significa «no hay recados».** Significa que la base de la cuenta no respondió. Se devuelve un error explícito en vez de una lista vacía precisamente para que no se interprete como ausencia de datos.\n\nNo se publica el objeto de detalle en bruto que genera el modelo ni el enlace a la grabación: el audio tiene su propio control de acceso y publicarlo aquí lo saltaría. Sí sale el texto que el producto considera el recado: resumen, acción requerida y datos de quien llamó.","operationId":"listRecados","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","in_progress","resolved","dismissed"]},"description":"Filtra por estado del recado."},{"name":"queue","in":"query","schema":{"type":"string","example":"ADMINISTRACION"},"description":"Filtra por departamento."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de recados","content":{"application/json":{"schema":{"type":"object","required":["data","source","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","vip","caller","created_at"],"properties":{"id":{"type":"string"},"reference_id":{"type":["string","null"]},"status":{"type":"string","enum":["pending","in_progress","resolved","dismissed"]},"priority":{"type":["string","null"],"example":"P2"},"queue":{"type":["string","null"],"example":"ADMINISTRACION"},"title":{"type":["string","null"]},"detail":{"type":["string","null"]},"action_required":{"type":["string","null"]},"vip":{"type":"boolean"},"caller":{"type":"object","properties":{"name":{"type":["string","null"]},"phone":{"type":["string","null"]},"company":{"type":["string","null"]},"email":{"type":["string","null"]}}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"source":{"type":"string","enum":["amai","tenant_database"],"description":"De qué almacén salió esta página."},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"503":{"description":"La base de recados de la cuenta no está disponible. **No es una lista vacía**: es «no pude preguntar». Reintenta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"recados_source_unavailable","message":"La base de recados de esta cuenta no está disponible ahora mismo. Reintenta."}}}}}}}},"/api/v1/alerts":{"get":{"tags":["Espacio de trabajo"],"summary":"Consultar los avisos configurados y el gasto que los dispara","description":"Devuelve los umbrales configurados **y** el gasto actual contra el que se comparan, en la misma respuesta. Por separado no sirven: harían falta dos llamadas y se acabaría comparando un umbral con un gasto de otro instante.\n\n**`is_exempt: true` significa que los umbrales de arriba no aplican.** La cuenta no se frena por saldo (es exenta o postpago). Una integración que lo ignore avisará todos los días de un problema inexistente, y una alerta que siempre salta se acaba silenciando — incluida la vez que importa.\n\nSolo lectura. Cambiar un umbral es escritura y va con otro scope: una clave de lectura que pudiera subirse el tope de gasto sería una clave de lectura solo de nombre.\n\nEsta operación no acepta parámetros.","operationId":"getAlerts","responses":{"200":{"description":"Umbrales, canales y estado actual","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["currency","thresholds","channels","current","is_exempt"],"properties":{"currency":{"type":"string","enum":["USD"]},"thresholds":{"type":"object","properties":{"low_balance_enabled":{"type":"boolean"},"low_balance_usd":{"type":"number","example":20},"critical_balance_usd":{"type":"number","example":5},"daily_spending_alert_enabled":{"type":"boolean"},"daily_spending_limit_usd":{"type":["number","null"]},"monthly_spending_cap_enabled":{"type":"boolean"},"monthly_spending_cap_usd":{"type":["number","null"]},"concurrency_alert_enabled":{"type":"boolean"},"concurrency_alert_threshold":{"type":"number"},"cooldown_minutes":{"type":"number","example":60}}},"channels":{"type":"object","properties":{"email":{"type":"boolean"},"in_app":{"type":"boolean"},"webhook":{"type":"boolean"}}},"current":{"type":"object","properties":{"balance_usd":{"type":"number","example":42.3175},"spend_today_usd":{"type":"number","example":1.24},"spend_month_usd":{"type":"number","example":38.9},"calls_today":{"type":"integer","example":21},"calls_month":{"type":"integer","example":640}}},"is_exempt":{"type":"boolean","description":"`true` cuando la cuenta no se frena por saldo. Los umbrales no aplican."}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Avisos"],"summary":"Configurar los avisos de la cuenta","description":"Define cuándo avisar por saldo bajo, por gasto diario o por concurrencia, y por qué canal. Actualización parcial: solo cambian los campos presentes en el cuerpo.\n\n## ⛔ Los dos mandos que esta operación NO toca\n\n`monthly_spending_cap` y `monthly_spending_cap_enabled` **no son editables por API**, aunque el panel los muestre junto al resto. Ese par no es un aviso: es un control de gasto. Subirlo aumenta la exposición de la cuenta y bajarlo o activarlo **corta el servicio** cuando se alcanza. Si los envías, se ignoran igual que cualquier otro campo no reconocido.\n\n## Sobre `notify_email`\n\nActivarlo **no manda ningún correo ahora**: es una preferencia. Lo que hace es permitir que el sistema avise por correo a la propia cuenta sobre su propio saldo cuando corresponda.\n\n## Coherencia entre umbrales\n\n`critical_balance_threshold` debe ser **menor o igual** que `low_balance_threshold`. Se comprueba cruzando el valor enviado con el YA GUARDADO, así que mandar solo uno de los dos también se valida. Si el resultado sería incoherente, la respuesta es 400 `invalid_thresholds` y no se escribe nada.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera:** hay una sola fila de preferencias por cuenta y la escritura se resuelve sobre ella. Repetir la petición no puede crear una segunda.","operationId":"updateAlertPreferences","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"low_balance_enabled":{"type":"boolean"},"low_balance_threshold":{"type":"integer","minimum":0,"maximum":1000000,"description":"Saldo (USD) por debajo del cual avisar."},"critical_balance_threshold":{"type":"integer","minimum":0,"maximum":1000000,"description":"Debe ser ≤ `low_balance_threshold`."},"daily_spending_alert_enabled":{"type":"boolean"},"daily_spending_limit":{"type":"integer","minimum":0,"maximum":1000000,"description":"Solo avisa. NO corta el servicio."},"concurrency_alert_enabled":{"type":"boolean"},"concurrency_alert_threshold":{"type":"integer","minimum":1,"maximum":100,"description":"Porcentaje de canales en uso a partir del cual avisar."},"notify_email":{"type":"boolean"},"notify_in_app":{"type":"boolean"},"notify_webhook":{"type":"boolean"},"alert_cooldown_minutes":{"type":"integer","minimum":1,"maximum":10080,"description":"Minutos entre dos avisos del mismo tipo. Máximo una semana."}}},"example":{"low_balance_threshold":25,"critical_balance_threshold":5,"notify_email":true}}}},"responses":{"200":{"description":"Preferencias actualizadas","content":{"application/json":{"schema":{"type":"object","properties":{"alert_preferences":{"type":"object","description":"No incluye `monthly_spending_cap*`: no se puede escribir desde aquí y publicarlo invitaría a intentarlo.","properties":{"distributor_id":{"type":"string","format":"uuid"},"low_balance_enabled":{"type":"boolean"},"low_balance_threshold":{"type":"number"},"critical_balance_threshold":{"type":"number"},"daily_spending_alert_enabled":{"type":"boolean"},"daily_spending_limit":{"type":["number","null"]},"concurrency_alert_enabled":{"type":"boolean"},"concurrency_alert_threshold":{"type":"integer"},"notify_email":{"type":"boolean"},"notify_in_app":{"type":"boolean"},"notify_webhook":{"type":"boolean"},"alert_cooldown_minutes":{"type":"integer"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/campaigns":{"get":{"tags":["Espacio de trabajo"],"summary":"Listar las campañas de la cuenta","description":"Las campañas con su progreso y sus métricas de entrega.\n\n**`dispatchable: false` significa que esa campaña no la ejecutará nadie.** Hoy es el caso de todas las de tipo `call`: no hay despachador que las acepte, ni en el panel ni por API. Se listan igualmente —ocultarlas haría pensar que se han borrado— pero el campo está para que nadie construya encima una interfaz que las presente como operativas.\n\n**No se publican los importes de la campaña.** Las columnas de coste estimado, coste real y presupuesto están en euros mientras todo el libro mayor de la plataforma va en dólares, y el coste real lo escribía el despachador que no existe: sumarlas daría un número que no cuadra con la factura, en una moneda que no es la de la factura. El gasto real está en `GET /api/v1/billing/transactions` y en `GET /api/v1/billing/usage`.","operationId":"listCampaigns","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["draft","scheduled","active","paused","completed","cancelled"]},"description":"Filtra por estado."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de campañas","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","name","type","status","dispatchable","progress","email"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"type":{"type":"string","example":"email"},"status":{"type":"string","example":"active"},"agent_id":{"type":["string","null"],"format":"uuid"},"dispatchable":{"type":"boolean","description":"`false` cuando ningún despachador puede ejecutarla. Hoy, todas las de tipo `call`."},"progress":{"type":"object","description":"⚠ `null` NO es cero. Un campo nulo aquí significa que la plataforma no mide ese dato, no que valga 0. Sumarlo como 0 produce un informe que afirma algo que nadie ha medido.","properties":{"total_contacts":{"type":"integer"},"called":{"type":["integer","null"],"description":"Contactos distintos con llamada originada, RECALCULADO desde la cola en cada lectura. `null` si el recálculo no estuvo disponible."},"connected":{"type":"integer","description":"Personas distintas que DESCOLGARON. Sale de `cdrs.answer_stamp`, el sello de la centralita — nunca del vocabulario de desenlaces, que archiva «no conectó» y «conectó y luego falló» bajo el mismo estado, ni inferido de una duración. Dejó de ser `null` el 2026-08-05, cuando la cobertura pasó a estar medida. No rellena hacia atrás: una campaña marcada antes del 2026-08-01 lee 0."},"converted":{"type":["null"],"description":"Siempre `null`. La conversión es un veredicto comercial, y su recuento hoy sólo se recalcula al terminar una llamada — así que una etapa movida por el camino de agenda no queda contada. Un cero afirmaría que no convirtió nadie. No se deriva de telefonía."},"total_minutes":{"type":["null"],"description":"Siempre `null`: la columna no tiene escritor. El consumo real y facturable está en `GET /api/v1/billing/usage`, en USD y contra el libro mayor."},"avg_duration_sec":{"type":["null"],"description":"Siempre `null`: la columna no tiene escritor."}}},"email":{"type":"object","description":"Cuatro contadores medidos y dos sin instrumentar. Los no medidos son `null`, nunca 0.","properties":{"sent":{"type":"integer"},"delivered":{"type":["null"],"description":"Siempre `null`: la confirmación de entrega la emite el proveedor SMTP y la plataforma no recibe ese acuse. NO es «0 entregados»."},"opened":{"type":"integer"},"clicked":{"type":"integer"},"bounced":{"type":["null"],"description":"Siempre `null`: los rebotes los notifica el proveedor y esa ruta no existe. Un `0` aquí se leería como «entregabilidad perfecta»."},"failed":{"type":"integer"}}},"scheduled_at":{"type":["string","null"],"format":"date-time"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Campañas"],"summary":"Crear una campaña de email","description":"Crea una campaña **en borrador**. `status` se fuerza a `draft` y `campaign_type` solo admite `email` o `sequence`.\n\nLas campañas de VOZ no se crean por API: gastan saldo y originan llamadas. Además están desactivadas en todo el producto.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n⚠ **No es idempotente.** Una campaña no tiene clave natural, así que un reintento tras un timeout puede dejar dos borradores. Son inertes y borrables: el daño es una campaña de más, nunca un envío de más.\n\n⚠ **Esta operación no envía ningún correo.** Toda la superficie de campañas de la API es inerte: crea, edita y organiza, pero **no despacha**. Despachar mueve dinero y manda correo a personas reales, y no se hace por API key.\n\nTampoco existe una operación de «programar»: hoy no hay ningún proceso que recoja una campaña de email por fecha, así que guardar una fecha de envío sería prometer algo que nada cumple.","operationId":"createCampaign","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200,"example":"Bienvenida julio"},"campaign_type":{"type":"string","enum":["email","sequence"],"default":"email","description":"`call` NO se admite: gasta saldo y origina llamadas."},"description":{"type":["string","null"],"maxLength":2000},"email_subject":{"type":["string","null"],"maxLength":500},"email_sender_name":{"type":["string","null"],"maxLength":200},"email_reply_to":{"type":["string","null"],"maxLength":320},"email_template_id":{"type":["string","null"],"format":"uuid","description":"Plantilla **de tu tenant**. Una ajena devuelve 404."},"sequence_delay_hours":{"type":"integer","minimum":0,"maximum":8760},"list_ids":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"},"description":"Listas de contactos **de tu tenant** que forman la audiencia. Todo o nada: si una sola no es tuya, no se crea la campaña."}}},"example":{"name":"Bienvenida julio","email_subject":"Bienvenido a bordo","email_template_id":"9f2c1a34-0000-4000-8000-000000000001","list_ids":["3b9e0000-0000-4000-8000-000000000002"]}}}},"responses":{"201":{"description":"Campaña creada, en borrador","content":{"application/json":{"schema":{"type":"object","properties":{"campaign":{"type":"object","description":"Campaña de email. **No incluye ningún dato de coste ni de minutos**: esos campos existen en la base de datos pero no se publican por API.","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","active","paused","completed","cancelled"],"description":"Estado actual. Por API solo se puede FIJAR `draft`, `paused` o `cancelled`; los demás los pone el sistema."},"campaign_type":{"type":"string","enum":["email","sequence","call"]},"email_template_id":{"type":["string","null"],"format":"uuid"},"email_subject":{"type":["string","null"]},"email_sender_name":{"type":["string","null"]},"email_reply_to":{"type":["string","null"]},"emails_sent":{"type":"integer"},"emails_delivered":{"type":"integer"},"emails_opened":{"type":"integer"},"emails_clicked":{"type":"integer"},"emails_bounced":{"type":"integer"},"emails_failed":{"type":"integer"},"sequence_delay_hours":{"type":["integer","null"]},"total_contacts":{"type":"integer"},"contacts_excluded":{"type":"integer"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"La escritura pública está desactivada (`PUBLIC_API_WRITE_ENABLED`), o la plantilla o alguna lista no existen en tu tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Plantilla de email no encontrada"}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/campaigns/{campaign_id}":{"get":{"tags":["Campañas"],"summary":"Consultar una campaña","description":"Devuelve una campaña concreta, con la MISMA proyección que devuelve el `PATCH` — leer y editar tienen que dar el mismo objeto del mismo recurso.\n\n**Para qué existe:** `PATCH` y `POST /dispatch` existen desde las primeras entregas y esta lectura no. Se podía pausar una campaña sin poder comprobar que quedó pausada, y despachar un lote sin poder leer los contadores de la campaña que acababas de mover.\n\n**No incluye ningún dato de coste ni de minutos.**\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `campaigns:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`.","operationId":"getCampaign","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"La campaña","content":{"application/json":{"schema":{"type":"object","properties":{"campaign":{"type":"object","description":"Campaña de email. **No incluye ningún dato de coste ni de minutos**: esos campos existen en la base de datos pero no se publican por API.","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","active","paused","completed","cancelled"],"description":"Estado actual. Por API solo se puede FIJAR `draft`, `paused` o `cancelled`; los demás los pone el sistema."},"campaign_type":{"type":"string","enum":["email","sequence","call"]},"email_template_id":{"type":["string","null"],"format":"uuid"},"email_subject":{"type":["string","null"]},"email_sender_name":{"type":["string","null"]},"email_reply_to":{"type":["string","null"]},"emails_sent":{"type":"integer"},"emails_delivered":{"type":"integer"},"emails_opened":{"type":"integer"},"emails_clicked":{"type":"integer"},"emails_bounced":{"type":"integer"},"emails_failed":{"type":"integer"},"sequence_delay_hours":{"type":["integer","null"]},"total_contacts":{"type":"integer"},"contacts_excluded":{"type":"integer"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Campañas"],"summary":"Editar una campaña de email","description":"Actualización parcial: solo cambian los campos presentes en el cuerpo.\n\n**`status` solo admite `draft`, `paused` y `cancelled`.** `active` se rechaza con un 400: es el estado que cualquier despachador lee como «esto puede salir», y una API que tiene prohibido gastar no deja campañas armadas.\n\nLos contadores de envío y todos los campos de coste son de solo lectura y no se pueden escribir, vengan o no en el cuerpo.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera:** es una actualización declarativa; repetirla deja el mismo estado.\n\n⚠ **Esta operación no envía ningún correo.** Toda la superficie de campañas de la API es inerte: crea, edita y organiza, pero **no despacha**. Despachar mueve dinero y manda correo a personas reales, y no se hace por API key.\n\nTampoco existe una operación de «programar»: hoy no hay ningún proceso que recoja una campaña de email por fecha, así que guardar una fecha de envío sería prometer algo que nada cumple.","operationId":"updateCampaign","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"email_subject":{"type":["string","null"],"maxLength":500},"email_sender_name":{"type":["string","null"],"maxLength":200},"email_reply_to":{"type":["string","null"],"maxLength":320},"email_template_id":{"type":["string","null"],"format":"uuid","description":"`null` desvincula la plantilla. Una plantilla ajena devuelve 404."},"sequence_delay_hours":{"type":"integer","minimum":0,"maximum":8760},"status":{"type":"string","enum":["draft","paused","cancelled"],"description":"`active` y `completed` NO se admiten."}}},"example":{"name":"Bienvenida julio (v2)","status":"paused"}}}},"responses":{"200":{"description":"Campaña actualizada","content":{"application/json":{"schema":{"type":"object","properties":{"campaign":{"type":"object","description":"Campaña de email. **No incluye ningún dato de coste ni de minutos**: esos campos existen en la base de datos pero no se publican por API.","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","active","paused","completed","cancelled"],"description":"Estado actual. Por API solo se puede FIJAR `draft`, `paused` o `cancelled`; los demás los pone el sistema."},"campaign_type":{"type":"string","enum":["email","sequence","call"]},"email_template_id":{"type":["string","null"],"format":"uuid"},"email_subject":{"type":["string","null"]},"email_sender_name":{"type":["string","null"]},"email_reply_to":{"type":["string","null"]},"emails_sent":{"type":"integer"},"emails_delivered":{"type":"integer"},"emails_opened":{"type":"integer"},"emails_clicked":{"type":"integer"},"emails_bounced":{"type":"integer"},"emails_failed":{"type":"integer"},"sequence_delay_hours":{"type":["integer","null"]},"total_contacts":{"type":"integer"},"contacts_excluded":{"type":"integer"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/campaigns/{campaign_id}/lists":{"get":{"tags":["Campañas"],"summary":"Consultar la audiencia de una campaña","description":"Las listas de contactos enganchadas a la campaña.\n\n**Para qué existe:** `POST` y `DELETE` sobre este mismo path existen desde la primera entrega y esta lectura no. Se podía montar y desmontar la audiencia de una campaña sin forma de preguntar cuál es — es decir, sin poder saber a quién iba a salir un despacho antes de lanzarlo.\n\nUna campaña **sin audiencia** devuelve `200` con `data: []`. El `404` queda para la campaña que no existe o no es tuya, y las dos responden exactamente lo mismo.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `campaigns:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`.","operationId":"getCampaignLists","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"La audiencia de la campaña","content":{"application/json":{"schema":{"type":"object","properties":{"campaign_id":{"type":"string","format":"uuid"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Leads webinar julio"},"description":{"type":["string","null"]},"source":{"type":"string","example":"api"},"contact_count":{"type":"integer","description":"Lo mantiene un disparador de la base de datos sobre `contact_list_members`; no es un campo editable."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Campañas"],"summary":"Añadir listas a la audiencia de una campaña","description":"Engancha listas de contactos a la campaña. **Todo o nada**: si una sola lista no es de tu tenant, no se enlaza ninguna — una audiencia a medias es peor que un error, porque no hay forma de ver la mitad que falta.\n\n**Idempotente sin cabecera:** la relación es única por `(campaña, lista)`, así que repetir la llamada es seguro.\n\n⚠ Enlazar una lista **no manda ningún correo**: solo escribe la relación.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"addCampaignLists","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["list_ids"],"properties":{"list_ids":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"string","format":"uuid"}}}},"example":{"list_ids":["3b9e0000-0000-4000-8000-000000000002"]}}}},"responses":{"200":{"description":"Listas enlazadas","content":{"application/json":{"schema":{"type":"object","properties":{"campaign_id":{"type":"string","format":"uuid"},"added":{"type":"integer","description":"Listas enlazadas."}}},"example":{"campaign_id":"8a20…","added":1}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}},"delete":{"tags":["Campañas"],"summary":"Quitar listas de la audiencia de una campaña","description":"Deshace la relación. **No borra las listas ni sus contactos.**\n\n**Idempotente sin cabecera:** quitar una lista que ya no estaba deja el mismo estado y responde 200. `removed` cuenta las relaciones que este borrado quitó de verdad, para que puedas distinguir «ya no estaba» de «la he quitado yo».\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"removeCampaignLists","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["list_ids"],"properties":{"list_ids":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"string","format":"uuid"}}}}}}},"responses":{"200":{"description":"Listas desenlazadas","content":{"application/json":{"schema":{"type":"object","properties":{"campaign_id":{"type":"string","format":"uuid"},"removed":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/sequences":{"get":{"tags":["Secuencias"],"summary":"Listar las secuencias de la cuenta","description":"Las secuencias de seguimiento del tenant.\n\nPor defecto **no incluye las archivadas**: `DELETE` archiva en vez de borrar, y una lista que las incluyera haría que un borrado pareciera no haber ocurrido. Se piden con `?status=archived`.\n\n### Cuándo deja de enviar\n\nEl motor comprueba esto **antes de cada paso**, y ninguna de las comprobaciones se puede desactivar:\n\n| Situación | Qué pasa | Estado de la inscripción |\n|---|---|---|\n| El contacto **responde** | Para la secuencia entera | `replied` |\n| El contacto se **da de baja** | Para y queda suprimido | `unsubscribed` |\n| El contacto está **suprimido** (lista de exclusión, opt-out, `do_not_email`) | No recibe **ni el primer paso** | `unsubscribed` |\n| La secuencia sale de `active` | Para hasta que se reactive | `paused` |\n| Se llegó al **paso 5** | Fin normal | `completed` |\n| Hace **menos de 24 h** del último correo a esa persona | Se **aplaza**, no se cancela | `active` |\n\nEl tope de 24 h cuenta el último correo que recibió esa persona **de tu cuenta**, venga de la secuencia que venga o de una campaña: dos secuencias solapadas no pueden escribirle dos veces el mismo día.\n\n**Todo correo lleva enlace de baja.** Si la plantilla no incluye `{{unsubscribe_url}}`, el motor añade un pie con el enlace. No es configurable.","operationId":"listSequences","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["draft","active","paused","archived"]},"description":"Filtra por estado."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de secuencias","content":{"application/json":{"schema":{"type":"object","required":["sequences","total"],"properties":{"sequences":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","archived"],"description":"`active` es el estado con el que el sistema envía correo de verdad. Se puede fijar por API, pero la secuencia tiene que caber en los topes del motor (ver `PATCH`)."},"steps":{"type":"array","items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}},"total_enrolled":{"type":"integer"},"total_completed":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Secuencias"],"summary":"Crear una secuencia de email","description":"Crea una secuencia **en borrador**: una lista ordenada de pasos «espera N horas y manda la plantilla X».\n\nCada `template_id` se valida contra **tu** tenant antes de guardar, y es todo o nada. No es celo: en el momento del envío la plantilla se carga por id, así que un paso que apuntara a la plantilla de otro cliente enviaría el contenido de ese cliente a tus contactos.\n\nUna secuencia en borrador **no envía nada**.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n⚠ **No es idempotente**: no hay clave natural.","operationId":"createSequence","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","steps"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200,"example":"Onboarding 3 toques"},"description":{"type":["string","null"],"maxLength":2000},"steps":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}}}},"example":{"name":"Onboarding 3 toques","steps":[{"template_id":"9f2c1a34-0000-4000-8000-000000000001","delay_hours":0},{"template_id":"9f2c1a34-0000-4000-8000-000000000002","delay_hours":48,"condition":"not_opened"}]}}}},"responses":{"201":{"description":"Secuencia creada, en borrador","content":{"application/json":{"schema":{"type":"object","properties":{"sequence":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","archived"],"description":"`active` es el estado con el que el sistema envía correo de verdad. Se puede fijar por API, pero la secuencia tiene que caber en los topes del motor (ver `PATCH`)."},"steps":{"type":"array","items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}},"total_enrolled":{"type":"integer"},"total_completed":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"La escritura pública está desactivada, o alguna plantilla referenciada no existe en tu tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Plantilla de email no encontrada"}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/email-templates":{"get":{"tags":["Plantillas"],"summary":"Listar las plantillas de email de la cuenta","description":"Las plantillas del tenant, de la más reciente a la más antigua.\n\nCierra el bucle que dejaba abierto crear y editar: sin esta operación, un `id` de plantilla perdido era irrecuperable y el único camino era crear otra.\n\n### `include_body`\n\n`html_body` admite 200 000 caracteres por plantilla, así que una página de 50 puede ser un JSON de varios megas. Con `include_body=false` se omiten `html_body` y `text_body` — que es lo que quieres si estás pintando un selector. **Se omiten, no vienen a `null`**: un `html_body: null` se leería como «esta plantilla no tiene cuerpo», que es otra cosa.\n\nScope `campaigns:read` · función de cuenta `manage_campaigns`.","operationId":"listEmailTemplates","parameters":[{"name":"include_body","in":"query","schema":{"type":"boolean","default":true},"description":"`false` omite `html_body` y `text_body`. Solo `true`/`false`."},{"name":"is_active","in":"query","schema":{"type":"boolean"},"description":"Filtra por plantillas activas o inactivas. Sin él, todas."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de plantillas","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"subject":{"type":"string"},"preview_text":{"type":["string","null"]},"html_body":{"type":"string"},"text_body":{"type":["string","null"]},"variables":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Plantillas"],"summary":"Crear una plantilla de email","description":"Guarda un cuerpo de correo reutilizable. **No manda nada**: la plantilla queda en reposo hasta que una campaña o una secuencia la use y alguien las despache.\n\n`html_body` admite hasta 200 000 caracteres. El tope es explícito porque la columna es `TEXT` y, sin él, una sola petición dejaría una fila que después ninguna consulta puede traer.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n⚠ **No es idempotente**: no hay clave natural. Un reintento puede dejar dos plantillas; son inertes y editables.","operationId":"createEmailTemplate","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","subject","html_body"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200,"example":"Bienvenida"},"subject":{"type":"string","maxLength":500,"example":"Hola {{nombre}}"},"html_body":{"type":"string","maxLength":200000,"example":"<p>Hola {{nombre}}</p>"},"text_body":{"type":["string","null"],"maxLength":200000},"preview_text":{"type":["string","null"],"maxLength":500},"variables":{"type":"array","maxItems":100,"items":{"type":"string","maxLength":100},"example":["nombre"]},"is_active":{"type":"boolean","default":true}}}}}},"responses":{"201":{"description":"Plantilla creada","content":{"application/json":{"schema":{"type":"object","properties":{"template":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"subject":{"type":"string"},"preview_text":{"type":["string","null"]},"html_body":{"type":"string"},"text_body":{"type":["string","null"]},"variables":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/email-templates/{template_id}":{"get":{"tags":["Plantillas"],"summary":"Ver una plantilla de email","description":"Una plantilla con su cuerpo completo. Es lo que hace falta para reconstruirla o para compararla antes de un `PATCH`.\n\nUna plantilla de otro tenant responde `404`, no `403`: un `403` confirmaría que ese identificador existe y convertiría la operación en un oráculo con el que enumerar las plantillas de los demás.\n\nScope `campaigns:read` · función de cuenta `manage_campaigns`.","operationId":"getEmailTemplate","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"La plantilla","content":{"application/json":{"schema":{"type":"object","required":["template"],"properties":{"template":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"subject":{"type":"string"},"preview_text":{"type":["string","null"]},"html_body":{"type":"string"},"text_body":{"type":["string","null"]},"variables":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Plantillas"],"summary":"Editar una plantilla de email","description":"Actualización parcial.\n\n⚠ **Una plantilla no se copia al usarla.** Las campañas y los pasos de secuencia guardan su id y leen el cuerpo **en el momento del envío**, así que editarla cambia lo que se enviará en todos los envíos futuros que la referencien, incluidos los de campañas ya creadas. Si necesitas cambiar el contenido sin tocar lo ya montado, crea una plantilla nueva y apunta la campaña a ella.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera.**","operationId":"updateEmailTemplate","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200},"subject":{"type":"string","maxLength":500},"html_body":{"type":"string","maxLength":200000},"text_body":{"type":["string","null"],"maxLength":200000},"preview_text":{"type":["string","null"],"maxLength":500},"variables":{"type":"array","maxItems":100,"items":{"type":"string"}},"is_active":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Plantilla actualizada","content":{"application/json":{"schema":{"type":"object","properties":{"template":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"subject":{"type":"string"},"preview_text":{"type":["string","null"]},"html_body":{"type":"string"},"text_body":{"type":["string","null"]},"variables":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/sip-trunks":{"get":{"tags":["Gasto"],"summary":"Listar los troncales SIP de la cuenta","operationId":"listSipTrunks","description":"Devuelve los troncales SIP externos de **tu** cuenta. Es la única operación de este dominio que **no** exige `Idempotency-Key` ni el interruptor de gasto: es una lectura y no cambia nada.\n\nTampoco exige el interruptor de escritura: es una operación de la Ola 1 de lectura y se publica siempre. Exige el scope `sip-trunks:read` y la función de cuenta `view_sip_trunks`.\n\n**La contraseña de cada troncal no se incluye.** En su lugar va `has_password`.","responses":{"200":{"description":"Listado.","content":{"application/json":{"schema":{"type":"object","properties":{"trunks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"sip_host":{"type":"string"},"sip_port":{"type":"integer"},"has_password":{"type":"boolean","description":"Si el troncal tiene contraseña configurada. **La contraseña en sí no se devuelve nunca**, por ninguna vía y para ningún rol."},"transport":{"type":"string","enum":["udp","tcp","tls"]},"auth_mode":{"type":"string","enum":["credentials","ip"]},"codecs":{"type":["string","null"]},"is_active":{"type":"boolean"},"notes":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}}}},"example":{"trunks":[{"id":"5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f","name":"Operador principal","sip_host":"sip.operador.example","sip_port":5060,"has_password":true,"transport":"udp","auth_mode":"credentials","codecs":"OPUS,G722,PCMU,PCMA","is_active":true,"notes":null,"created_at":"2026-07-20T09:12:00.000Z","updated_at":null}]}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}},"post":{"tags":["Gasto"],"summary":"Crear un troncal SIP","operationId":"createSipTrunk","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\n**La operación no cuesta nada.** Lo que cuesta es el tráfico que después circule por el troncal, y lo paga tu cuenta por el camino de siempre.\n\nMáximo **5 troncales por cuenta**; el sexto responde `409 trunk_limit_reached`.\n\nScope `sip-trunks:write` · función de cuenta `create_sip_trunks`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","sip_host"],"properties":{"name":{"type":"string"},"sip_host":{"type":"string"},"sip_port":{"type":"integer","minimum":1,"maximum":65535,"default":5060},"username":{"type":["string","null"],"description":"Solo de escritura. **No aparece en ninguna respuesta.** Con `auth_mode: \"credentials\"` es la otra mitad de la credencial del troncal, así que se trata igual que la contraseña: se puede escribir y no se puede leer. Para saber si el troncal tiene credencial configurada está `has_password`.","writeOnly":true},"password":{"type":["string","null"],"description":"Solo de escritura. No aparece en ninguna respuesta.","writeOnly":true},"transport":{"type":"string","enum":["udp","tcp","tls"],"default":"udp"},"auth_mode":{"type":"string","enum":["credentials","ip"],"default":"credentials"},"codecs":{"type":["string","null"]},"notes":{"type":["string","null"]},"is_active":{"type":"boolean"}}},"example":{"name":"Operador principal","sip_host":"sip.operador.example","sip_port":5060,"username":"amai-tenant","password":"…","transport":"udp"}}}},"responses":{"201":{"description":"Troncal creado. **Sin la contraseña en la respuesta.**","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"trunk":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"sip_host":{"type":"string"},"sip_port":{"type":"integer"},"has_password":{"type":"boolean","description":"Si el troncal tiene contraseña configurada. **La contraseña en sí no se devuelve nunca**, por ninguna vía y para ningún rol."},"transport":{"type":"string","enum":["udp","tcp","tls"]},"auth_mode":{"type":"string","enum":["credentials","ip"]},"codecs":{"type":["string","null"]},"is_active":{"type":"boolean"},"notes":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"trunk":{"id":"5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f","name":"Operador principal","sip_host":"sip.operador.example","sip_port":5060,"has_password":true,"transport":"udp","auth_mode":"credentials","codecs":"OPUS,G722,PCMU,PCMA","is_active":true,"notes":null,"created_at":"2026-07-20T09:12:00.000Z","updated_at":null}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflictos de idempotencia, o `trunk_limit_reached` (máximo 5 por cuenta).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"trunk_limit_reached","message":"Máximo 5 troncales externos por cuenta."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/billing/subscription":{"get":{"tags":["Facturación"],"summary":"Ver el plan contratado y su ciclo de facturación","description":"El plan de la cuenta y, si existe, el ciclo de la suscripción de pago.\n\n**`billing_cycle` es `null` para las cuentas `free` y `payg`, y eso es lo normal**: nunca pasaron por una pasarela de pago, así que no tienen ciclo. `plan` viene siempre relleno — es el plan real de la cuenta, no una deducción a partir de la suscripción.\n\nNo se publican los identificadores de la pasarela de pago (son de nuestra cuenta) ni la matriz de capacidades del plan: qué puede hacer la cuenta ya lo dice la propia API con un `403 capability_required` cuando no puede.\n\nEsta operación no acepta parámetros.","operationId":"getSubscription","responses":{"200":{"description":"Plan y ciclo","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["plan","billing_cycle"],"properties":{"plan":{"type":"object","properties":{"slug":{"type":["string","null"],"example":"starter"},"name":{"type":["string","null"],"example":"Starter"},"description":{"type":["string","null"]},"price_monthly_usd":{"type":["number","null"],"example":29},"billing_mode":{"type":["string","null"],"example":"subscription"},"limits":{"type":["object","null"],"additionalProperties":true,"description":"Límites del plan (canales, retención, subcuentas…)."}}},"billing_cycle":{"type":["object","null"],"description":"`null` cuando la cuenta no tiene suscripción de pago.","properties":{"status":{"type":["string","null"],"example":"active"},"current_period_start":{"type":["string","null"],"format":"date-time"},"current_period_end":{"type":["string","null"],"format":"date-time"},"cancel_at_period_end":{"type":"boolean"},"canceled_at":{"type":["string","null"],"format":"date-time"},"trial_end":{"type":["string","null"],"format":"date-time"}}}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Gasto"],"summary":"Contratar o cambiar de plan","operationId":"createSubscription","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\nLa cuota mensual del plan, a la tarjeta de tu cuenta.\n\nHay dos desenlaces y conviene distinguirlos:\n- **`checkout_required` (201)** — alta nueva. Devuelve `checkout_url` y **no se ha cobrado nada** hasta que alguien la complete.\n- **`activated` / `upgraded` (200)** — ya había una suscripción de pago viva y el cambio lo resuelve el proveedor con prorrateo, sin redirección. **Aquí sí hay efecto económico inmediato.**\n\nEl plan que pidas se valida contra el catálogo real; un identificador desconocido es `400`, nunca un plan inventado. Y el plan de la cuenta **solo cambia cuando el pago se confirma**.\n\nScope `billing:write` · función de cuenta `manage_billing`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","enum":["starter","agency","partner","enterprise"],"description":"`free` no se contrata: es el estado por defecto de la cuenta y responde `400`."}}},"example":{"plan":"starter"}}}},"responses":{"200":{"description":"Cambio de plan resuelto sin redirección.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"subscription":{"type":"object","properties":{"status":{"type":"string","enum":["activated","upgraded"]},"plan":{"type":"string"}}}}},"example":{"subscription":{"status":"upgraded","plan":"agency"}}}}},"201":{"description":"Sesión de pago creada. Nada cobrado todavía.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"subscription":{"type":"object","properties":{"status":{"type":"string","enum":["checkout_required"]},"plan":{"type":"string"},"checkout_url":{"type":["string","null"],"format":"uri"}}}}},"example":{"subscription":{"status":"checkout_required","plan":"starter","checkout_url":"https://checkout.stripe.com/c/pay/cs_live_d4e5f6"}}}}},"400":{"description":"`invalid_plan` (no está en el catálogo), `plan_not_purchasable` (el plan Free), `subscription_failed` (regla de negocio: cuenta de demostración, etc.), o `missing_idempotency_key` / `invalid_idempotency_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_plan","message":"\"plan\" debe ser uno de: free, starter, …"}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}},"delete":{"tags":["Gasto"],"summary":"Cancelar el plan al final del periodo","operationId":"cancelSubscription","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\n**Nada, y no devuelve nada.** La cancelación es al final del periodo ya pagado: el plan sigue activo hasta `ends_at` y no se emite reembolso. Se dice explícitamente porque la expectativa natural de «cancelar» es «recuperar el dinero», y aquí no lo es.\n\nScope `billing:write` · función de cuenta `manage_billing`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"responses":{"200":{"description":"Cancelación programada.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"subscription":{"type":"object","properties":{"canceled":{"type":"boolean"},"ends_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"subscription":{"canceled":true,"ends_at":"2026-08-15T00:00:00.000Z"}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflictos de idempotencia, o `no_active_subscription` — no hay plan de pago que cancelar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"no_active_subscription","message":"No tienes una suscripción activa"}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/billing/addons":{"get":{"tags":["Facturación"],"summary":"Ver los add-ons disponibles y los contratados","description":"El catálogo de add-ons y, en cada uno, la suscripción de tu cuenta si la tiene.\n\n**Esta operación no gasta nada.** Contratar es `POST /api/v1/billing/addons` y dar de baja es `DELETE /api/v1/billing/addons/{addon_type}`; las dos mueven dinero y van detrás de sus interruptores. Aquí solo se mira.\n\nSin esta lectura solo se podía averiguar qué hay contratado **intentando contratarlo** y leyendo el `409 addon_already_active`, que es descubrir el estado provocando un error.\n\n### Qué se lista\n\nLos add-ons vigentes del catálogo (`purchasable: true`) **más los heredados**: tipos ya retirados de los que tu cuenta conserva una suscripción viva. Salen con `purchasable: false` porque no se pueden volver a contratar, pero se listan — omitirlos escondería un cargo recurrente que sigues pagando.\n\n`subscription` es `null` cuando no tienes ese add-on. Los identificadores de la pasarela de pago no se publican: son de nuestra cuenta.\n\nEsta operación no acepta parámetros. Scope `billing:read` · función de cuenta `view_billing`.","operationId":"listAddons","responses":{"200":{"description":"El catálogo con el estado de cada add-on en esta cuenta","content":{"application/json":{"schema":{"type":"object","required":["data","total"],"properties":{"total":{"type":"integer","example":1},"data":{"type":"array","items":{"type":"object","required":["addon_type","name","price_usd","interval","purchasable","subscription"],"properties":{"addon_type":{"type":"string","example":"concurrency_channels"},"name":{"type":"string","example":"Canales Extra"},"description":{"type":"string"},"price_usd":{"type":"number","example":10,"description":"Precio unitario AL CLIENTE, por `interval`."},"interval":{"type":"string","enum":["month"]},"supports_quantity":{"type":"boolean"},"min_quantity":{"type":"integer","example":1},"max_quantity":{"type":"integer","example":100},"purchasable":{"type":"boolean","description":"`false` = retirado del catálogo; aparece solo porque lo tienes."},"requires_contact":{"type":"boolean"},"subscription":{"type":["object","null"],"description":"`null` cuando la cuenta no tiene este add-on.","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":["string","null"],"example":"active"},"quantity":{"type":"integer","example":10},"price_usd":{"type":"number","example":100,"description":"Precio total AL CLIENTE de esta suscripción, por periodo."},"current_period_start":{"type":["string","null"],"format":"date-time"},"current_period_end":{"type":["string","null"],"format":"date-time"},"canceled_at":{"type":["string","null"],"format":"date-time"},"suspended_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":["string","null"],"format":"date-time"}}}}}}}},"example":{"data":[{"addon_type":"concurrency_channels","name":"Canales Extra","description":"Canales de llamada simultánea adicionales. 1 canal incluido gratis.","price_usd":10,"interval":"month","supports_quantity":true,"min_quantity":1,"max_quantity":100,"purchasable":true,"requires_contact":false,"subscription":{"id":"7a1e0c22-3f4b-4a55-9c10-88b0d1e2f3a4","status":"active","quantity":10,"price_usd":100,"current_period_start":"2026-07-01T00:00:00Z","current_period_end":"2026-08-01T00:00:00Z","canceled_at":null,"suspended_at":null,"created_at":"2026-05-14T11:20:03Z"}}],"total":1}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Gasto"],"summary":"Contratar un add-on","operationId":"activateAddon","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\nLa cuota recurrente del add-on (canales extra, retención ampliada…), a la tarjeta de tu cuenta. Igual que con el plan: `checkout_required` (201) **no ha cobrado nada**; `activated` (200) sí tiene efecto inmediato.\n\n**El precio lo pone nuestro catálogo, no tu petición.** El cuerpo solo dice *qué* add-on y *cuánta* cantidad; no existe ningún campo de precio. Los add-ons retirados del catálogo dejan de contratarse aquí el mismo día que dejan de contratarse en el panel.\n\nScope `billing:write` · función de cuenta `manage_billing`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["addon_type"],"properties":{"addon_type":{"type":"string","description":"Identificador del add-on. Un valor no contratable responde `400` con la lista de los que sí lo son."},"quantity":{"type":"integer","default":1,"description":"Acotada al rango del add-on; fuera de rango, `400 invalid_quantity` con el rango en el mensaje."}}},"example":{"addon_type":"concurrency_channels","quantity":5}}}},"responses":{"200":{"description":"Add-on activado sin redirección.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"addon":{"type":"object","properties":{"status":{"type":"string","enum":["activated"]},"addon_type":{"type":"string"},"quantity":{"type":"integer"}}}}},"example":{"addon":{"status":"activated","addon_type":"concurrency_channels","quantity":5}}}}},"201":{"description":"Sesión de pago creada. Nada cobrado todavía.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"addon":{"type":"object","properties":{"status":{"type":"string","enum":["checkout_required"]},"addon_type":{"type":"string"},"quantity":{"type":"integer"},"checkout_url":{"type":["string","null"],"format":"uri"}}}}},"example":{"addon":{"status":"checkout_required","addon_type":"concurrency_channels","quantity":5,"checkout_url":"https://checkout.stripe.com/c/pay/cs_live_g7h8i9"}}}}},"400":{"description":"`invalid_addon_type`, `invalid_quantity`, `addon_failed` (regla de negocio), o `missing_idempotency_key` / `invalid_idempotency_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_addon_type","message":"\"addon_type\" debe ser uno de: concurrency_channels, …"}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflictos de idempotencia, o `addon_already_active` — ya tienes ese add-on vivo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"addon_already_active","message":"Ya tienes una suscripción activa de Canales Extra"}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/calls/{call_id}":{"get":{"tags":["Llamadas"],"summary":"Ver una llamada","description":"Una llamada concreta, con sus etiquetas y —si tuvo agente de IA— su transcripción.\n\nLos campos de la llamada son **exactamente** los de cada elemento de `GET /api/v1/calls`: los sirve la misma función, así que la lista y el detalle no pueden decir cosas distintas del mismo importe.\n\n`call_id` acepta el id público (`call_<ULID>`) o el UUID heredado del proveedor de IA, igual que el resto de la superficie de llamadas.\n\n### Las grabaciones no se publican aquí\n\nEl audio se sirve con una URL firmada de vida corta y tiene una **retención por plan** (7 días en las cuentas gratuitas). Publicar un enlace por API exige decidir su caducidad, su cuota y qué se responde cuando el fichero ya se purgó: es una operación propia, no un campo de este contrato.\n\n### 404 también cuando la llamada es de otro tenant\n\nUn identificador ajeno y uno inexistente devuelven lo mismo, a propósito: un 403 confirmaría que el ajeno es real.","operationId":"getCall","security":[{"bearerAuth":[]}],"parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"},"example":"call_01J8ZKQ4T7X2M9NB3VC5RD6PYE","description":"Id público (`call_…`) o UUID de la llamada."}],"responses":{"200":{"description":"La llamada.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","direction","from","to","status","cost","labels","ai"],"properties":{"id":{"type":"string","example":"call_01J8ZKQ4T7X2M9NB3VC5RD6PYE"},"started_at":{"type":["string","null"],"format":"date-time"},"ended_at":{"type":["string","null"],"format":"date-time"},"direction":{"type":"string","enum":["inbound","outbound"]},"from":{"type":"string","example":"+34600111222","description":"Origen en E.164, o cadena vacía cuando no se registró ninguno — que es el caso de las llamadas que salen por la troncal del proveedor de IA."},"to":{"type":"string","example":"+34919469552"},"destination_label":{"type":["string","null"],"example":"Spain Mobile"},"duration_sec":{"type":"integer","example":87},"billed_sec":{"type":"integer","example":90},"status":{"type":"string","enum":["completed","failed","no_answer","busy"]},"hangup_cause":{"type":["string","null"],"example":"NORMAL_CLEARING"},"sip_account":{"type":["string","null"],"example":"1001","description":"La extensión que originó la llamada. Se resuelve con `GET /api/v1/telephony/extensions`."},"call_origin":{"type":["string","null"],"example":"ai_agent"},"cost":{"type":"object","required":["currency","telephony_usd","ai_usd","total_usd"],"description":"Importes **para el cliente**, en USD. No es nuestro coste de proveedor ni nuestro margen: esos no salen por esta API en ningún endpoint.","properties":{"currency":{"type":"string","enum":["USD"]},"rate_per_minute":{"type":["number","null"],"example":0.012},"telephony_usd":{"type":"number","example":0.024},"ai_usd":{"type":"number","example":0.081,"description":"0 en llamadas sin agente de IA."},"total_usd":{"type":"number","example":0.105}}},"labels":{"type":"array","description":"Las etiquetas **vivas** de la llamada. Las que el usuario retiró conservan su fila con una lápida y no aparecen aquí. El catálogo completo está en `GET /api/v1/call-labels`.","items":{"type":"object","required":["key","display"],"properties":{"key":{"type":"string","example":"004"},"display":{"type":"string","example":"Interesado"}}}},"ai":{"type":["object","null"],"description":"La conversación, cuando la llamada tuvo una pierna de IA. **`null`, no un objeto con cadenas vacías**, cuando no la tuvo: una llamada humana por SIP no tiene transcripción, y un objeto vacío sugeriría que la hubo y salió en blanco.","properties":{"transcript":{"type":["string","null"]},"summary":{"type":["string","null"]},"sentiment":{"type":["string","null"],"example":"positive"}}}}}}},"example":{"data":{"id":"call_01J8ZKQ4T7X2M9NB3VC5RD6PYE","started_at":"2026-07-24T10:14:02.000Z","ended_at":"2026-07-24T10:15:29.000Z","direction":"inbound","from":"+34600111222","to":"+34919469552","destination_label":"Spain Fixed","duration_sec":87,"billed_sec":90,"status":"completed","hangup_cause":"NORMAL_CLEARING","sip_account":"1001","call_origin":"ai_agent","cost":{"currency":"USD","rate_per_minute":0.009,"telephony_usd":0.0135,"ai_usd":0.0812,"total_usd":0.0947},"labels":[{"key":"004","display":"Interesado"}],"ai":{"transcript":"Agente: Buenos días, ¿en qué puedo ayudarle?…","summary":"El cliente pregunta por el horario de recogida.","sentiment":"positive"}}}}}},"400":{"description":"El identificador no tiene un formato reconocible. **400 y no 404**: es un error del integrador y decírselo no confirma ni desmiente que exista ninguna llamada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"invalid_id_format","message":"\"call_id\" debe ser el id público (`call_…`) o el UUID de la llamada."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/call-labels":{"get":{"tags":["Llamadas"],"summary":"Listar el catálogo de etiquetas de llamada","description":"Todas las claves que `POST /api/v1/calls/{call_id}/labels` reconoce en esta cuenta, y cuáles de ellas se pueden escribir.\n\nSin esto, la escritura de etiquetas —que ya está publicada— era adivinanza: las predefinidas están en el código, las propias del tenant solo en su base de datos, y las de cola se rechazaban con un 400 que era la única documentación de su existencia.\n\n### `writable: false` es una propiedad de la clave, no un permiso\n\nLas claves de cola son un enrutado disfrazado de etiqueta: escribir una crea un recado en la base de datos del cliente y origina un webhook saliente. Y lo son **por tenant** — la misma clave puede ser inerte en una cuenta y ser una cola en otra —, así que el campo se calcula contra las colas de ESTA cuenta.\n\nCuando una clave es a la vez predefinida (o propia) y cola, aparece **una sola vez** y como cola: lo que hay que saber es el efecto de escribirla, no su procedencia.\n\nSin paginación: el catálogo de un tenant son decenas de claves como mucho.","operationId":"listCallLabels","security":[{"bearerAuth":[]}],"parameters":[],"responses":{"200":{"description":"El catálogo: primero las predefinidas, luego las propias, luego las colas.","content":{"application/json":{"schema":{"type":"object","required":["data","total"],"properties":{"data":{"type":"array","items":{"type":"object","required":["key","display","kind","color","writable"],"properties":{"key":{"type":"string","example":"004","description":"La clave que se manda a `POST /api/v1/calls/{call_id}/labels`."},"display":{"type":"string","example":"Interesado"},"kind":{"type":"string","enum":["predefined","custom","queue"],"description":"`predefined` = disponible en todas las cuentas · `custom` = creada por este tenant en el panel · `queue` = cola de departamento."},"color":{"type":"string","example":"#60a5fa","description":"Hexadecimal, para pintar la etiqueta en un CRM externo. No se publican las clases del sistema de diseño del panel."},"writable":{"type":"boolean","description":"`false` en las claves de cola. **No es un permiso: es una propiedad de la clave.** Escribir una etiqueta de cola crea un recado en la base de datos del cliente y dispara un webhook saliente, así que `POST /api/v1/calls/{call_id}/labels` la rechaza con `400 queue_label_not_allowed`."}}}},"total":{"type":"integer","example":12}}},"example":{"data":[{"key":"004","display":"Interesado","kind":"predefined","color":"#60a5fa","writable":true},{"key":"c_a1b2c3","display":"Pendiente de presupuesto","kind":"custom","color":"#10b981","writable":true},{"key":"q_soporte","display":"Soporte","kind":"queue","color":"#f59e0b","writable":false}],"total":3}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/telephony/extensions":{"get":{"tags":["Telefonía"],"summary":"Listar las extensiones SIP del tenant","description":"Las extensiones SIP de la cuenta: quién es cada una, qué identidad presenta al llamar, cuántas llamadas simultáneas admite y qué prefijos puede marcar.\n\n**Es el diccionario que le faltaba a `GET /api/v1/calls`**: cada llamada trae un campo `sip_account` con un nombre de usuario, y hasta ahora no había forma de saber a qué correspondía ni qué límites tenía.\n\n### La contraseña SIP no sale, y no es una omisión\n\n`has_password` dice si la extensión tiene credencial configurada. La credencial en sí **no se publica por esta API en ningún caso**. Quien la tenga puede registrar un teléfono contra nuestra centralita y cursar llamadas **a cargo de esta cuenta** desde cualquier parte del mundo, sin pasar por la API ni por el panel. Se obtiene y se rota desde el panel, con sesión de usuario.\n\nTampoco salen el `accountcode`, el dominio ni el contexto de la centralita: describen nuestra topología, no la cuenta del cliente.\n\n### Alcance\n\nSolo las extensiones **de esta cuenta**. A diferencia de `GET /api/v1/numbers`, aquí no se incluyen las de subcuentas que esta cuenta factura: una extensión es un punto de registro contra la centralita, no una línea de factura.","operationId":"listSipExtensions","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Las extensiones de la cuenta, las más recientes primero.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","username","is_active","has_password"],"properties":{"id":{"type":"string","format":"uuid"},"username":{"type":"string","example":"1001","description":"El identificador de la extensión. Es **el mismo valor** que el campo `sip_account` de cada llamada en `GET /api/v1/calls`, así que sirve para cruzar histórico con inventario sin ninguna tabla intermedia."},"caller_id":{"type":"object","description":"Identidad que esta extensión presenta al llamar.","properties":{"name":{"type":["string","null"],"example":"Recepción"},"number":{"type":["string","null"],"example":"+34919469552"}}},"max_concurrent_calls":{"type":["integer","null"],"example":5,"description":"Llamadas simultáneas que admite esta extensión. `null` = sin techo propio; manda el límite de la cuenta. **Es el dato que explica por qué una segunda llamada no entra.**"},"allowed_prefixes":{"type":["array","null"],"items":{"type":"string"},"example":["34","1"],"description":"Prefijos que esta extensión tiene permitido marcar. `null` = sin restricción. Una llamada a un prefijo fuera de la lista se rechaza antes de cursarse."},"is_active":{"type":"boolean"},"owner_email":{"type":["string","null"],"format":"email"},"has_password":{"type":"boolean","description":"Si la extensión tiene credencial SIP configurada. **La credencial en sí no se publica por ninguna vía de esta API**, en ningún endpoint y con ningún scope: quien la tuviera podría cursar llamadas a cargo de esta cuenta sin pasar por aquí. Para obtenerla o rotarla hay que entrar al panel."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"9f1c2b40-3a5e-4c11-9d77-2b0e5a8c1f34","username":"1001","caller_id":{"name":"Recepción","number":"+34919469552"},"max_concurrent_calls":5,"allowed_prefixes":["34"],"is_active":true,"owner_email":"recepcion@cliente.com","has_password":true,"created_at":"2026-03-11T09:12:04.000Z","updated_at":"2026-07-02T16:40:11.000Z"}],"pagination":{"limit":50,"offset":0,"total":1,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/telephony/profiles":{"get":{"tags":["Telefonía"],"summary":"Listar los perfiles de telefonía del tenant","description":"Un perfil agrupa números bajo un mismo **techo de llamadas simultáneas** (`max_channels`).\n\nEs la respuesta a la avería más difícil de diagnosticar de este producto: *«mi segunda llamada no entra»*. Cuando el techo se alcanza, las llamadas sobrantes se rechazan **antes** de llegar a la centralita — así que no dejan rastro en `GET /api/v1/calls`. Sin este dato, el síntoma es «llamadas que no aparecen» y no hay forma de ver contra qué se está chocando.\n\nNo se publican los destinos de entrada del perfil (`inbound_target`, `inbound_failover`): son puntos de nuestra propia centralita.\n\nSin paginación: un tenant tiene perfiles del orden de unidades, y la respuesta trae `total` para que quede explícito que no hay una segunda página escondida.","operationId":"listTelephonyProfiles","security":[{"bearerAuth":[]}],"parameters":[],"responses":{"200":{"description":"Los perfiles de la cuenta. El perfil por defecto va primero.","content":{"application/json":{"schema":{"type":"object","required":["data","total"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","name","is_active","is_default","numbers_count"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Producción"},"max_channels":{"type":["integer","null"],"example":30,"description":"Llamadas simultáneas que admite el perfil entero. `null` = sin techo propio."},"is_active":{"type":"boolean"},"is_default":{"type":"boolean","description":"El perfil al que se asignan los números nuevos si no se dice otra cosa."},"numbers_count":{"type":"integer","example":4,"description":"Números **de esta cuenta** asignados al perfil. No incluye los de subcuentas: los canales del perfil los consume su propietario."},"created_at":{"type":"string","format":"date-time"}}}},"total":{"type":"integer","example":2}}},"example":{"data":[{"id":"1d8a7f22-6c34-4b90-8f01-77c3e9a2b451","name":"Producción","max_channels":30,"is_active":true,"is_default":true,"numbers_count":4,"created_at":"2026-02-18T11:03:22.000Z"}],"total":1}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/team/invites":{"get":{"tags":["Equipo"],"summary":"Listar invitaciones del equipo","description":"Las invitaciones del tenant, por defecto solo las **pendientes**: ni aceptadas ni caducadas, que son las únicas sobre las que se puede actuar.\n\nCierra el hueco de `GET /api/v1/members`, que solo ve a quien ya está dentro: quien fue invitado y todavía no ha entrado era invisible por API.\n\n**El `token` de la invitación no se devuelve, en ningún caso y bajo ningún parámetro.** Ese token es la credencial: quien lo tiene puede canjearlo y darse de alta como miembro activo de la cuenta. Publicarlo convertiría esta lectura en una vía de alta.\n\n**Devuelve datos personales** (el email de cada invitado).\n\n**Requiere la capacidad de cuenta `manage_team`.** Hoy ningún plan comercial la incluye: se concede caso a caso. Si recibes `403 capability_required`, no es la API key — pídele a tu distribuidor que habilite la gestión de equipo en la cuenta.","operationId":"listTeamInvites","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","expired","accepted","all"],"default":"pending"},"description":"Cualquier otro valor devuelve `400 invalid_field`: un filtro inválido nunca se ignora, porque ignorarlo devolvería la lista entera con un 200 delante."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de invitaciones, de la más reciente a la más antigua","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","email","status","created_at","expires_at"],"properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email","description":"**Dato personal.** Es la identidad de la invitación; sin él la fila no significa nada."},"role":{"type":["string","null"],"enum":["manager","operator","billing","viewer",null],"description":"Rol del sistema. `null` cuando la invitación lleva un rol personalizado."},"role_label":{"type":"string","example":"Operador","description":"Etiqueta legible del rol, ya sea del sistema o personalizado."},"mode":{"type":"string","enum":["link","credentials"],"description":"`link`: se envió un enlace de aceptación. `credentials`: se creó el usuario y se le mandó una contraseña. **El token de la invitación NUNCA se publica** — es la credencial que permite canjearla y crear una cuenta dentro del tenant."},"delivery_status":{"type":["string","null"],"example":"sent","description":"Último resultado del envío del correo."},"delivery_attempts":{"type":"integer","example":1},"status":{"type":"string","enum":["pending","expired","accepted"],"description":"**Derivado, no almacenado.** `pending` = ni aceptada ni caducada. Es el único estado sobre el que se puede actuar."},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"accepted_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"6b1e2c40-3a5f-4d21-9c88-0e1f2a3b4c5d","email":"nuevo@ejemplo.com","role":"operator","role_label":"Operador","mode":"link","delivery_status":"sent","delivery_attempts":1,"status":"pending","created_at":"2026-07-20T09:00:00Z","expires_at":"2026-07-27T09:00:00Z","accepted_at":null}],"pagination":{"limit":50,"offset":0,"total":1,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/team/seats":{"get":{"tags":["Equipo"],"summary":"Consultar las plazas de equipo","description":"Cuántas plazas ocupa el equipo y cuántas quedan. Es la pregunta previa a cualquier automatización de altas: «¿puedo incorporar a alguien más?».\n\nEl cómputo es EXACTAMENTE el del panel, incluidos los dos casos que sorprenden: el propietario de la cuenta ocupa plaza aunque no tenga fila de miembro, y **las invitaciones pendientes también ocupan** — una plaza reservada es una plaza gastada.\n\nUsa `unlimited` y no compares `limit` con un número: el valor que representa «sin límite» es un centinela interno y puede cambiar sin previo aviso.\n\nNo devuelve el plan contratado: eso es `billing:read`, que es otra autorización.\n\n**Requiere la capacidad de cuenta `manage_team`.** Hoy ningún plan comercial la incluye: se concede caso a caso. Si recibes `403 capability_required`, no es la API key — pídele a tu distribuidor que habilite la gestión de equipo en la cuenta.","operationId":"getTeamSeats","responses":{"200":{"description":"Uso de plazas del tenant","content":{"application/json":{"schema":{"type":"object","required":["seats"],"properties":{"seats":{"type":"object","required":["active_members","pending_invites","used","limit","remaining","unlimited"],"properties":{"active_members":{"type":"integer","example":4},"pending_invites":{"type":"integer","example":1,"description":"Cuentan como plaza ocupada."},"used":{"type":"integer","example":5},"limit":{"type":"integer","example":5},"remaining":{"type":"integer","example":0,"description":"Nunca negativo."},"unlimited":{"type":"boolean","description":"`true` cuando el plan no impone techo. Úsalo en vez de comparar `limit`."}}}}},"example":{"seats":{"active_members":4,"pending_invites":1,"used":5,"limit":5,"remaining":0,"unlimited":false}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/team/roles":{"get":{"tags":["Equipo"],"summary":"Listar los roles personalizados del tenant","description":"El catálogo de roles personalizados definidos en la cuenta, **incluidos los que aún no tiene asignados nadie** — que es la diferencia con deducirlos recorriendo `GET /api/v1/members`.\n\n**Devuelve la identidad del rol, nunca sus permisos.** Salen nombre, etiqueta, color e icono; no sale qué puede hacer cada rol. Publicar la matriz de permisos de una cuenta sería publicar el mapa de su autorización —qué rol factura, cuál borra llamadas, cuál toca telefonía—, y eso no le hace falta a quien sincroniza un directorio.\n\n**No hay operación de creación ni de edición de roles, y no la habrá:** definir un rol es definir permisos, y una API key equivale al propietario de la cuenta. Un rol nuevo con los permisos que uno elija sería auto-escalada en una sola llamada.\n\n**Requiere la capacidad de cuenta `manage_team`.** Hoy ningún plan comercial la incluye: se concede caso a caso. Si recibes `403 capability_required`, no es la API key — pídele a tu distribuidor que habilite la gestión de equipo en la cuenta.","operationId":"listTeamRoles","responses":{"200":{"description":"Catálogo de roles personalizados, por etiqueta","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","slug","label"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","example":"facturacion"},"label":{"type":"string","example":"Facturación"},"color":{"type":["string","null"],"example":"#00a86b"},"icon":{"type":["string","null"],"example":"receipt"},"created_at":{"type":["string","null"],"format":"date-time"}}}}}},"example":{"data":[{"id":"c1a2b3d4-0000-4000-8000-abcdefabcdef","slug":"facturacion","label":"Facturación","color":"#00a86b","icon":"receipt","created_at":"2026-03-11T10:22:00Z"}]}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/announcements":{"get":{"tags":["Anuncios"],"summary":"Listar los anuncios internos de la cuenta","description":"Los anuncios que **la cuenta ha publicado** para su equipo: borradores, publicados y archivados.\n\n⚠ **No es el buzón de una persona.** El panel tiene además una vista «recibidos» con estado de lectura por usuario; esa no existe por API y no existirá: una API key no es una persona, así que no hay buzón que devolver ni lectura que registrar. Lo que se publica aquí es el plano de la CUENTA.\n\nNo hay operaciones de creación ni de edición: publicar un anuncio dispara notificaciones al equipo y, en cuentas internas, al changelog público. Es un efecto hacia fuera que se decide aparte.","operationId":"listAnnouncements","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["draft","published","archived"]},"description":"Sin el parámetro salen los tres estados. Un valor no permitido devuelve `400 invalid_field` en vez de ignorarse."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de anuncios, del más reciente al más antiguo","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","source","title","status","priority","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"source":{"type":"string","enum":["tenant","platform"],"description":"`tenant`: lo publicó la cuenta. `platform`: lo publicó AMAI para la cuenta."},"title":{"type":"string","example":"Nuevo horario de soporte"},"summary":{"type":["string","null"],"example":"A partir del lunes, 8:00–20:00."},"body":{"type":["string","null"],"description":"Cuerpo completo, en texto."},"category":{"type":["string","null"],"enum":["announcement","feature","improvement","maintenance","thanks",null]},"status":{"type":"string","enum":["draft","published","archived"]},"priority":{"type":"string","enum":["normal","high","critical"],"description":"`critical` dispara notificación al equipo en el momento de publicarse."},"show_as_popup":{"type":"boolean"},"is_public":{"type":"boolean","description":"`true` solo en cuentas internas de AMAI: significa que además salió al changelog público. Para un tenant comercial es siempre `false`."},"version_tag":{"type":["string","null"],"example":"v2.4.0"},"targeting":{"type":"object","description":"A quién iba dirigido. `roles: null` y `member_count: null` significan difusión a todo el equipo.","properties":{"roles":{"type":["array","null"],"items":{"type":"string"},"example":["owner","manager"]},"member_count":{"type":["integer","null"],"example":3,"description":"**Cuántos** miembros se señalaron, no cuáles. Los identificadores de las personas apuntadas no se publican: el número responde la pregunta sin volcar el listado."}}},"created_by":{"type":["string","null"],"format":"uuid","description":"Identificador del autor. **No se resuelve a nombre ni a email**: cruza con `GET /api/v1/members` si necesitas la persona."},"published_at":{"type":["string","null"],"format":"date-time"},"archived_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"8f3c1d20-4b6a-4e11-9d55-2c3e4f5a6b7c","source":"tenant","title":"Nuevo horario de soporte","summary":"A partir del lunes, 8:00–20:00.","body":"Ampliamos la franja de atención…","category":"announcement","status":"published","priority":"normal","show_as_popup":false,"is_public":false,"version_tag":null,"targeting":{"roles":["owner","manager"],"member_count":null},"created_by":"11111111-1111-4111-8111-111111111111","published_at":"2026-07-22T07:30:00Z","archived_at":null,"created_at":"2026-07-21T18:02:00Z","updated_at":"2026-07-22T07:30:00Z"}],"pagination":{"limit":50,"offset":0,"total":1,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/announcements/{announcement_id}":{"get":{"tags":["Anuncios"],"summary":"Leer un anuncio interno","description":"Un anuncio de la cuenta, con la misma forma que trae el listado.\n\n**No incluye estadísticas de alcance.** El panel muestra quién leyó y quién descartó cada anuncio, persona a persona; eso es un registro de comportamiento de gente identificada y no sale por esta vía. Si algún día hace falta, irá en su propia operación, con su scope y su declaración de datos personales.","operationId":"getAnnouncement","parameters":[{"name":"announcement_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"El anuncio","content":{"application/json":{"schema":{"type":"object","required":["announcement"],"properties":{"announcement":{"type":"object","required":["id","source","title","status","priority","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"source":{"type":"string","enum":["tenant","platform"],"description":"`tenant`: lo publicó la cuenta. `platform`: lo publicó AMAI para la cuenta."},"title":{"type":"string","example":"Nuevo horario de soporte"},"summary":{"type":["string","null"],"example":"A partir del lunes, 8:00–20:00."},"body":{"type":["string","null"],"description":"Cuerpo completo, en texto."},"category":{"type":["string","null"],"enum":["announcement","feature","improvement","maintenance","thanks",null]},"status":{"type":"string","enum":["draft","published","archived"]},"priority":{"type":"string","enum":["normal","high","critical"],"description":"`critical` dispara notificación al equipo en el momento de publicarse."},"show_as_popup":{"type":"boolean"},"is_public":{"type":"boolean","description":"`true` solo en cuentas internas de AMAI: significa que además salió al changelog público. Para un tenant comercial es siempre `false`."},"version_tag":{"type":["string","null"],"example":"v2.4.0"},"targeting":{"type":"object","description":"A quién iba dirigido. `roles: null` y `member_count: null` significan difusión a todo el equipo.","properties":{"roles":{"type":["array","null"],"items":{"type":"string"},"example":["owner","manager"]},"member_count":{"type":["integer","null"],"example":3,"description":"**Cuántos** miembros se señalaron, no cuáles. Los identificadores de las personas apuntadas no se publican: el número responde la pregunta sin volcar el listado."}}},"created_by":{"type":["string","null"],"format":"uuid","description":"Identificador del autor. **No se resuelve a nombre ni a email**: cruza con `GET /api/v1/members` si necesitas la persona."},"published_at":{"type":["string","null"],"format":"date-time"},"archived_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"announcement":{"id":"8f3c1d20-4b6a-4e11-9d55-2c3e4f5a6b7c","source":"tenant","title":"Nuevo horario de soporte","summary":"A partir del lunes, 8:00–20:00.","body":"Ampliamos la franja de atención…","category":"announcement","status":"published","priority":"normal","show_as_popup":false,"is_public":false,"version_tag":null,"targeting":{"roles":["owner","manager"],"member_count":null},"created_by":"11111111-1111-4111-8111-111111111111","published_at":"2026-07-22T07:30:00Z","archived_at":null,"created_at":"2026-07-21T18:02:00Z","updated_at":"2026-07-22T07:30:00Z"}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/billing/pending-orders":{"get":{"tags":["Facturación"],"summary":"Listar compras de número sin completar","description":"Las compras de número que se quedaron a medias: pagadas a medias, o esperando una verificación de identidad.\n\nEs el único sitio de la API donde se ven. Un pedido sin completar **no aparece** en `GET /api/v1/numbers` (todavía no hay número), ni en `GET /api/v1/billing/transactions` (no se ha cobrado nada), ni en el consumo. Desde fuera es un servicio que el cliente cree tener y no tiene.\n\n### Los dos estados, y cómo se desatasca cada uno\n\n- **`pending_payment`** — falta pagar. Se completa desde el panel.\n- **`kyc_required`** — el país exige verificación de identidad. Mira `kyc_status`: `collecting` o `approved` significan que la documentación ya está y solo falta el cobro; cualquier otro valor significa que falta documentación del cliente.\n\nLos pedidos terminados (completados, fallidos o cancelados) **no se listan**: no hay nada que hacer con ellos.\n\n### Precios\n\n`pricing` es siempre lo que se te cobra a ti. El coste de proveedor de AMAI no sale por esta API en ningún endpoint. `first_charge_usd` es alta + primer mes, que es lo que se cobra al reanudar; `wallet_credit_applied_usd` es una referencia calculada con el saldo del momento en que se creó el pedido y **se recalcula al pagar**.","operationId":"listPendingDidOrders","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de pedidos abiertos, del más reciente al más antiguo","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","country","pricing","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":["string","null"],"enum":["pending_payment","kyc_required",null]},"kyc_status":{"type":["string","null"],"example":"collecting","description":"`null` salvo en países con verificación obligatoria. `collecting`/`approved` = solo falta el cobro."},"country":{"type":["string","null"],"example":"España"},"country_code":{"type":["string","null"],"example":"ES"},"city":{"type":["string","null"],"example":"Madrid"},"area_code":{"type":["string","null"],"example":"91"},"pricing":{"type":"object","description":"Importes **para el cliente**, en USD. No es el coste de proveedor de AMAI ni su margen: esos no salen por esta API.","required":["currency","setup_usd","monthly_usd","first_charge_usd"],"properties":{"currency":{"type":"string","enum":["USD"]},"setup_usd":{"type":"number","example":5},"monthly_usd":{"type":"number","example":5},"wallet_credit_applied_usd":{"type":"number","example":3.2,"description":"Referencia; se recalcula con el saldo del momento del cobro."},"first_charge_usd":{"type":"number","example":10,"description":"Alta + primer mes. Lo que se cobra al reanudar."}}},"created_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"2f6f1c9a-77a0-4b21-9a11-0d1b2c3e4f55","status":"kyc_required","kyc_status":"collecting","country":"España","country_code":"ES","city":"Madrid","area_code":"91","pricing":{"currency":"USD","setup_usd":5,"monthly_usd":5,"wallet_credit_applied_usd":3.2,"first_charge_usd":10},"created_at":"2026-07-19T10:02:11Z","updated_at":"2026-07-19T10:44:02Z"}],"pagination":{"limit":50,"offset":0,"total":1,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/billing/concurrency":{"get":{"tags":["Facturación"],"summary":"Consultar el cupo de llamadas simultáneas","description":"Cuántas llamadas simultáneas admite tu cuenta y cuántas hay ahora mismo.\n\nLa concurrencia es el techo que corta llamadas **en caliente**: al llegar al máximo, la siguiente llamada no entra, y desde fuera eso se parece a una avería. Consulta este endpoint antes de lanzar tráfico en paralelo y no descubrirás el techo chocándote con él.\n\n`channels.in_use` es una **foto del instante**, no un contador acumulado: la respuesta nunca se cachea.\n\n### Ráfaga\n\nCon la ráfaga apagada, `burst.max_channels` es igual a `channels.total`: no hay margen extra hasta que alguien la enciende. Con ella encendida, las llamadas que pasan del techo normal se facturan a `burst.rate_per_minute_usd`.\n\n### Comprar canales\n\nEsta operación **no gasta nada**. Comprar canales es `POST /api/v1/billing/addons` con `addon_type: \"concurrency_channels\"`, que sí mueve dinero y va detrás de su propio interruptor y del alta explícita de la cuenta.\n\nEl cupo **no se suma entre cuentas**: es una propiedad de la cuenta que emite las llamadas, no de quien las paga. Una agencia que paga el consumo de una subcuenta no comparte su techo.","operationId":"getConcurrency","responses":{"200":{"description":"El cupo de la cuenta y su ocupación en este instante","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["currency","channels","burst","extra_channel_pricing"],"properties":{"currency":{"type":"string","enum":["USD"]},"channels":{"type":"object","required":["base","extra","total","in_use","available"],"properties":{"base":{"type":"integer","example":5,"description":"Canales del plan. Una cuenta sin límites explícitos NO es una cuenta sin límite."},"extra":{"type":"integer","example":10,"description":"Canales comprados como add-on."},"total":{"type":"integer","example":15},"in_use":{"type":"integer","example":3,"description":"Llamadas vivas AHORA."},"available":{"type":"integer","example":12}}},"burst":{"type":"object","required":["enabled","max_channels","rate_per_minute_usd"],"properties":{"enabled":{"type":"boolean"},"max_channels":{"type":"integer","example":45,"description":"Igual a `channels.total` cuando la ráfaga está apagada."},"rate_per_minute_usd":{"type":"number","example":0.1,"description":"Precio por minuto AL CLIENTE de una llamada en ráfaga."}}},"extra_channel_pricing":{"type":"object","required":["unit_price_usd","valid_quantities"],"properties":{"unit_price_usd":{"type":"number","example":10},"valid_quantities":{"type":"array","items":{"type":"integer"},"example":[1,5,10,20]}}}}}}},"example":{"data":{"currency":"USD","channels":{"base":5,"extra":10,"total":15,"in_use":3,"available":12},"burst":{"enabled":true,"max_channels":45,"rate_per_minute_usd":0.1},"extra_channel_pricing":{"unit_price_usd":10,"valid_quantities":[1,5,10,20]}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/documents":{"get":{"tags":["Documentos"],"summary":"Listar el archivo documental de la cuenta","description":"El archivo documental de tu cuenta: qué documentos hay, cómo están clasificados y cuándo entraron.\n\n**Este archivo no vive en AMAI.** Vive en la instancia de Paperless-ngx de tu cuenta; AMAI custodia sus credenciales y hace de intermediario. Por eso la respuesta puede traer un `503` (la cuenta tiene el módulo habilitado pero no conectado) o un `502` (tu archivo no respondió) — dos situaciones que no son un fallo de esta API.\n\n### Lo que este endpoint NO hace\n\n**No descarga documentos.** El contenido de un expediente son datos personales y, en algunas cuentas, documentación regulatoria; una API key equivale al propietario de la cuenta, así que abrir la descarga convertiría una clave entregada a un tercero en una copia íntegra del archivo. Descargar necesita su propio permiso y su propia traza por documento, y no está abierto. Tampoco subir, borrar ni reclasificar.\n\n### Paginación\n\n⚠ El archivo pagina por **páginas completas**: `offset` tiene que ser múltiplo de `limit` (`0`, `25`, `50`… con `limit=25`). Un desplazamiento intermedio devuelve `400` en vez de una ventana distinta de la que pediste. El máximo de `limit` es 50, que es el que impone el archivo.\n\n### Fechas\n\n`start_date` y `end_date` son **opcionales y sin ventana por defecto**: sin ellas se lista el archivo entero. Los dos extremos son **inclusivos**, igual que en el resto de la API.","operationId":"listDocuments","parameters":[{"name":"search","in":"query","schema":{"type":"string","maxLength":200,"example":"contrato"},"description":"Búsqueda de texto completo sobre el archivo (título y contenido indexado)."},{"name":"document_type","in":"query","schema":{"type":"integer","minimum":1,"example":4},"description":"Filtra por tipo de documento. El identificador es el que devuelve `data[].document_type.id`."},{"name":"tag","in":"query","schema":{"type":"integer","minimum":1,"example":12},"description":"Filtra por etiqueta. El identificador es el de `data[].tags[].id`."},{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-01-01"},"description":"Fecha del documento a partir de la cual listar, ISO-8601. **Inclusivo.** Opcional: sin él no hay límite inferior."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fecha del documento hasta la que listar, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Opcional."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"minimum":1,"maximum":50},"description":"Tamaño de página. Máximo 50."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página del archivo, del documento más reciente al más antiguo","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","title","document_type","tags","created_at","added_at"],"properties":{"id":{"type":"integer","example":1841,"description":"Identificador dentro del archivo del cliente. Es un entero, no un UUID: lo asigna el archivo, no AMAI."},"title":{"type":"string","example":"Contrato de mantenimiento 2026"},"document_type":{"type":["object","null"],"description":"`null` cuando el documento no está clasificado.","properties":{"id":{"type":"integer","example":4},"name":{"type":"string","example":"Contrato"}}},"tags":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer","example":12},"name":{"type":"string","example":"Proveedores"}}}},"created_at":{"type":"string","format":"date-time","description":"La fecha DEL DOCUMENTO, la que le asignó el archivo."},"added_at":{"type":"string","format":"date-time","description":"Cuándo entró en el archivo. Suele ser posterior."},"archive_serial_number":{"type":["string","null"],"example":"2026-0417","description":"Referencia de archivo físico, si la cuenta la usa."}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":1841,"title":"Contrato de mantenimiento 2026","document_type":{"id":4,"name":"Contrato"},"tags":[{"id":12,"name":"Proveedores"}],"created_at":"2026-03-02T00:00:00Z","added_at":"2026-03-04T09:12:44Z","archive_serial_number":"2026-0417"}],"pagination":{"limit":25,"offset":0,"total":1841,"has_more":true}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"502":{"description":"El archivo documental de la cuenta no respondió, respondió con un error, o rechazó nuestras credenciales. **El motivo exacto no viaja en la respuesta**: describiría una credencial que AMAI custodia. Queda en nuestros registros.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"upstream_error","message":"El archivo documental de esta cuenta no respondió. Reinténtalo en unos minutos."}}}}},"503":{"description":"La cuenta tiene el módulo habilitado pero **no conectado**. No es un problema de plan (eso sería un `403 capability_required`) ni de clave: falta configurar la integración, y eso lo hace un operador.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"integration_not_configured","message":"Esta cuenta tiene el archivo documental habilitado pero no conectado. Contacta con tu distribuidor para configurar la integración."}}}}}}}},"/api/v1/inbox/mailboxes":{"get":{"tags":["Inbox"],"operationId":"listInboxMailboxes","summary":"Listar los buzones compartidos que esta clave puede leer","description":"La entrada del dominio: sin esto, `mailbox_id` es un identificador que no se puede adivinar.\n\n**Devuelve solo los buzones compartidos del tenant.** Los personales de cada empleado, los de departamento y los virtuales de Microsoft 365 no aparecen — y responden `404` si se piden por su id. El motivo no es técnico: el producto decidió que ni el propietario de la cuenta lee el correo de su plantilla sin un acto deliberado, y una API key no es nadie a quien se le pueda haber concedido eso. Para exponer un buzón a una integración, márcalo como compartido desde el panel.\n\n**Sin paginación**, y no es un olvido: el conjunto se resuelve entero en memoria y los buzones compartidos de un tenant se cuentan con los dedos. Un `limit`/`offset` aquí prometería un troceado que por debajo no existe.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Los buzones compartidos, sin bloque `pagination`.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","provider","from_address","scope"],"properties":{"id":{"type":"string","description":"El `mailbox_id` del resto del dominio."},"provider":{"type":"string","enum":["microsoft_tenant","outlook_oauth","gmail_oauth"]},"from_address":{"type":"string","example":"soporte@tuempresa.com"},"from_name":{"type":["string","null"],"example":"Soporte"},"scope":{"type":"string","enum":["distributor"],"description":"Siempre `distributor` por esta superficie. Se publica igualmente para que el día que el producto abra otro alcance el cliente ya lo esté leyendo, en vez de enterarse por un cambio de contrato."}}}}}},"example":{"data":[{"id":"6f1c4a2e-9b03-4a7d-9f2c-1d5e8b0a7c31","provider":"microsoft_tenant","from_address":"soporte@tuempresa.com","from_name":"Soporte","scope":"distributor"}]}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/inbox/{mailbox_id}/folders":{"get":{"tags":["Inbox"],"operationId":"listInboxFolders","summary":"Listar las carpetas de un buzón, con sus contadores","description":"Las carpetas del buzón y **cuántos mensajes sin leer** tiene cada una. Es la fuente del dato que el panel resume en la insignia de la barra lateral, pero por buzón, en una sola llamada y sin tragarse los errores de los buzones que fallan.\n\nSe consulta **en vivo** al proveedor de correo de la cuenta en cada petición.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."}],"responses":{"200":{"description":"Las carpetas del buzón.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","display_name"],"properties":{"id":{"type":"string"},"display_name":{"type":"string","example":"Bandeja de entrada"},"parent_folder_id":{"type":["string","null"]},"well_known":{"type":["string","null"],"enum":["inbox","sent","drafts","deleted","junk","archive",null],"description":"`null` cuando el proveedor no la clasifica. No es lo mismo que «ninguna»."},"total_count":{"type":["integer","null"]},"unread_count":{"type":["integer","null"]}}}}}},"example":{"data":[{"id":"AAMkAGI2...","display_name":"Bandeja de entrada","parent_folder_id":null,"well_known":"inbox","total_count":1284,"unread_count":7}]}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/inbox/{mailbox_id}/messages":{"get":{"tags":["Inbox"],"operationId":"listInboxMessages","summary":"Listar los mensajes de un buzón compartido","description":"**Se pagina con `cursor`, no con `offset`.** Los mensajes no están en nuestra base de datos: los sirve el proveedor de correo de la cuenta, y su paginación es un testigo opaco que **no trae un total**. Fingir un `offset` obligaría a recorrer las N-1 páginas anteriores en cada petición, y fingir un `total` sería inventarse un número. Se recorre mientras `next_cursor` venga distinto de `null`.\n\nPor eso mismo **enviar `offset` es un `400`**, no un parámetro ignorado: ignorarlo devolvería la primera página con un `200` delante mientras el cliente cree estar en la tercera.\n\nLos elementos vienen **sin cuerpo** — `body_preview` y nada más. Para el cuerpo, el detalle de cada mensaje.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."},{"name":"folder_id","in":"query","schema":{"type":"string"},"description":"Carpeta a listar. Por defecto, la bandeja de entrada."},{"name":"q","in":"query","schema":{"type":"string"},"description":"Búsqueda del lado del proveedor (`$search` de Graph)."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"minimum":1,"maximum":100},"description":"Tamaño de página. Máximo 100 — es el techo del proveedor, pedir más no trae más."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"El `next_cursor` de la respuesta anterior. Opaco: no se construye a mano."}],"responses":{"200":{"description":"Una página de mensajes y el cursor de la siguiente.","content":{"application/json":{"schema":{"type":"object","required":["data","next_cursor"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","conversation_id","subject","date","has_attachments","is_read"],"properties":{"id":{"type":"string"},"conversation_id":{"type":"string","description":"El hilo al que pertenece. Se pasa a `GET …/conversations/{conversation_id}`."},"subject":{"type":"string"},"body_preview":{"type":"string","description":"El resumen que da el proveedor. El cuerpo completo solo sale en el detalle."},"from":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}},"to":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"cc":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"date":{"type":"string","format":"date-time"},"has_attachments":{"type":"boolean"},"is_read":{"type":"boolean"},"is_flagged":{"type":["boolean","null"]},"importance":{"type":["string","null"],"enum":["low","normal","high",null]},"folder_id":{"type":["string","null"]}}}},"next_cursor":{"type":["string","null"],"description":"`null` = no hay más páginas. Es la condición de parada del bucle."}}},"example":{"data":[{"id":"AAMkAGI2TG93AAA=","conversation_id":"AAQkAGI2TG93AAQ=","subject":"Incidencia con el pedido 4417","body_preview":"Buenos días, el pedido llegó incompleto…","from":{"name":"Ana Ruiz","email":"ana@cliente.com"},"to":[{"name":"Soporte","email":"soporte@tuempresa.com"}],"cc":[],"date":"2026-07-26T08:41:00Z","has_attachments":true,"is_read":false,"is_flagged":false,"importance":"normal","folder_id":"AAMkAGI2..."}],"next_cursor":"EwAAABYAAABLb2R..."}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/inbox/{mailbox_id}/messages/{message_id}":{"get":{"tags":["Inbox"],"operationId":"getInboxMessage","summary":"Leer un mensaje con su cuerpo","description":"El mensaje completo, con `body_html` y `body_text`.\n\n**Solo lectura.** Marcar como leído, destacar, mover de carpeta, borrar y responder no se abren por API — el buzón es compartido y lo está leyendo gente; una integración que marque como leído lo que procesa hace desaparecer avisos de la bandeja de alguien que nunca los vio. Para enviar correo por API existe `POST /api/v1/email/send`.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."},{"name":"message_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` de un mensaje del listado. Es opaco y lo asigna el proveedor."}],"responses":{"200":{"description":"El mensaje, con su cuerpo.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","conversation_id","subject","date","has_attachments","is_read"],"properties":{"id":{"type":"string"},"conversation_id":{"type":"string","description":"El hilo al que pertenece. Se pasa a `GET …/conversations/{conversation_id}`."},"subject":{"type":"string"},"body_preview":{"type":"string","description":"El resumen que da el proveedor. El cuerpo completo solo sale en el detalle."},"from":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}},"to":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"cc":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"date":{"type":"string","format":"date-time"},"has_attachments":{"type":"boolean"},"is_read":{"type":"boolean"},"is_flagged":{"type":["boolean","null"]},"importance":{"type":["string","null"],"enum":["low","normal","high",null]},"folder_id":{"type":["string","null"]},"body_html":{"type":["string","null"],"description":"El cuerpo tal y como lo entrega el proveedor, **con sus referencias `cid:` intactas**. El panel las sustituye por `data:` incrustando cada imagen; aquí no, porque convertiría un mensaje con tres logotipos en una respuesta de varios megas que nadie pidió. Las imágenes se piden por el endpoint de adjuntos, casando `cid:` con `content_id`."},"body_text":{"type":["string","null"]},"bcc":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"internet_message_id":{"type":["string","null"],"description":"El `Message-ID` de la cabecera RFC 5322. Sirve para casar con otro sistema."}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/inbox/{mailbox_id}/messages/{message_id}/attachments":{"get":{"tags":["Inbox"],"operationId":"listInboxAttachments","summary":"Listar los adjuntos de un mensaje","description":"Las fichas, **nunca los bytes**: nombre, tipo, tamaño y si la imagen va incrustada en el cuerpo. Es lo que permite decidir por tamaño y por tipo antes de descargar nada.\n\n`content_id` es la pieza que casa un `src=\"cid:…\"` del `body_html` con el adjunto que lo resuelve.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."},{"name":"message_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` de un mensaje del listado. Es opaco y lo asigna el proveedor."}],"responses":{"200":{"description":"Las fichas de los adjuntos.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","name","content_type","size_bytes","is_inline"],"properties":{"id":{"type":"string"},"name":{"type":"string","example":"factura-2026-07.pdf"},"content_type":{"type":"string","example":"application/pdf"},"size_bytes":{"type":"integer","example":184320},"is_inline":{"type":"boolean","description":"`true` = imagen incrustada en el cuerpo, no un fichero que el remitente adjuntó."},"content_id":{"type":["string","null"],"description":"El `Content-ID` al que apunta un `src=\"cid:…\"` del `body_html`."}}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/inbox/{mailbox_id}/messages/{message_id}/attachments/{attachment_id}":{"get":{"tags":["Inbox"],"operationId":"getInboxAttachment","summary":"Descargar el contenido de un adjunto (base64)","description":"Los bytes del adjunto, **en base64 dentro del sobre JSON de siempre**. El panel devuelve `application/octet-stream` porque su cliente es un navegador y quiere el diálogo de descarga; el cliente de esta API es un programa, y para él eso rompe dos cosas: el contrato (toda la superficie v1 responde JSON) y el sobre de error (un `502` en medio de un flujo de bytes no se distingue de un fichero corrupto).\n\n**Tope de 15 MiB.** Por encima se responde `413` **con el tamaño real dentro**, en vez de intentarlo y tumbar el proceso: base64 infla un 33 % y el fichero tiene que caber en memoria dos veces. Un adjunto que no cabe es un hecho del adjunto y el cliente puede decidir qué hacer; un contenedor sin memoria no es un hecho de nadie. El tamaño se comprueba en la ficha **antes** de pedir los bytes, y otra vez sobre los bytes recibidos — el proveedor no siempre informa del mismo tamaño que entrega.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."},{"name":"message_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` de un mensaje del listado. Es opaco y lo asigna el proveedor."},{"name":"attachment_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` de una ficha del listado de adjuntos."}],"responses":{"200":{"description":"El adjunto, con sus bytes en base64.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","name","content_type","size_bytes","content_base64"],"properties":{"id":{"type":"string"},"name":{"type":"string","example":"factura-2026-07.pdf"},"content_type":{"type":"string","example":"application/pdf"},"size_bytes":{"type":"integer","example":184320},"is_inline":{"type":"boolean","description":"`true` = imagen incrustada en el cuerpo, no un fichero que el remitente adjuntó."},"content_id":{"type":["string","null"],"description":"El `Content-ID` al que apunta un `src=\"cid:…\"` del `body_html`."},"content_base64":{"type":"string","description":"Los bytes del adjunto, codificados en base64."}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"413":{"description":"El adjunto supera los 15 MiB. La respuesta trae el tamaño real, y su ficha sigue disponible en el listado de adjuntos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"attachment_too_large","message":"El adjunto ocupa 22020096 bytes y el máximo por petición son 15728640. Su ficha sigue disponible en el listado de adjuntos."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/inbox/{mailbox_id}/conversations/{conversation_id}":{"get":{"tags":["Inbox"],"operationId":"getInboxConversation","summary":"Leer un hilo completo","description":"Todos los mensajes del hilo, del más antiguo al más reciente, con la misma proyección que el listado — **sin cuerpo**. Para el cuerpo de uno concreto, su detalle.\n\n**Sin paginación**, y el motivo es del proveedor: devuelve el hilo entero en una consulta y no da testigo. Un `limit` aquí serviría una primera página y el cliente pediría una segunda que nunca llegaría.\n\nRequiere el scope `inbox:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"mailbox_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `id` que devuelve `GET /api/v1/inbox/mailboxes`."},{"name":"conversation_id","in":"path","required":true,"schema":{"type":"string"},"description":"El `conversation_id` de cualquier mensaje del hilo."}],"responses":{"200":{"description":"Los mensajes del hilo, del más antiguo al más reciente.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","conversation_id","subject","date","has_attachments","is_read"],"properties":{"id":{"type":"string"},"conversation_id":{"type":"string","description":"El hilo al que pertenece. Se pasa a `GET …/conversations/{conversation_id}`."},"subject":{"type":"string"},"body_preview":{"type":"string","description":"El resumen que da el proveedor. El cuerpo completo solo sale en el detalle."},"from":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}},"to":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"cc":{"type":"array","items":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"example":"Ana Ruiz"},"email":{"type":"string","example":"ana@cliente.com"}}}},"date":{"type":"string","format":"date-time"},"has_attachments":{"type":"boolean"},"is_read":{"type":"boolean"},"is_flagged":{"type":["boolean","null"]},"importance":{"type":["string","null"],"enum":["low","normal","high",null]},"folder_id":{"type":["string","null"]}}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, es de otro tenant, o existe en esta cuenta pero **no es un buzón compartido**. Los tres responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No hay un buzón compartido con ese identificador en esta cuenta. Una API key solo alcanza los buzones compartidos del tenant, nunca los asignados a una persona."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}},"501":{"description":"El buzón está conectado pero su lector todavía no existe (hoy, los buzones de Gmail). Aparece en `GET /api/v1/inbox/mailboxes` y no se puede leer. Se declara con su propio código en vez de devolver un `502`, que haría reintentar para siempre.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_implemented","message":"El proveedor \"gmail_oauth\" no tiene lector."}}}}},"502":{"description":"El proveedor de correo de la cuenta (Microsoft Graph, Gmail) no ha respondido, o sus credenciales ya no se pueden leer. **No es un fallo nuestro y es reintentable**; por eso no es un `500`. El detalle del proveedor queda en nuestros registros y nunca en la respuesta: sus mensajes de error arrastran identificadores del tenant.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"provider_error":{"summary":"El proveedor no responde","value":{"error":{"code":"provider_error","message":"El proveedor de correo de esta cuenta no ha respondido. Reintenta en unos segundos."}}},"mailbox_unavailable":{"summary":"Las credenciales del buzón no se pueden leer","value":{"error":{"code":"mailbox_unavailable","message":"Las credenciales de este buzón no se han podido leer. Vuelve a conectarlo desde el panel de integraciones."}}}}}}}}}},"/api/v1/email/connections":{"get":{"tags":["Email"],"operationId":"listEmailConnections","summary":"Listar las identidades de envío de email","description":"Las direcciones desde las que esta cuenta puede enviar correo, y si siguen vivas. Es la respuesta a «¿qué pongo en `from` de `POST /api/v1/email/send`?».\n\n**Nunca devuelve credenciales.** Ni la contraseña SMTP, ni el `refresh_token`, ni el identificador de tenant de Microsoft, ni el texto del último error del proveedor — que suele traer el usuario de la cuenta dentro. Lo único que se publica sobre el secreto es `has_credentials`, un booleano.\n\n**Conectar, reconectar o dar de baja una integración no se puede hacer por API**, y no es un pendiente: son flujos OAuth de navegador y escrituras de credenciales. Se hacen desde el panel.\n\nLa fila-marcador de la conexión de empresa de Microsoft 365 (`scope=tenant_connection`) no aparece: no es una identidad desde la que se pueda enviar nada.\n\nRequiere el scope `email-configs:read` y la capacidad de cuenta `send_emails`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"provider","in":"query","schema":{"type":"string","enum":["outlook_oauth","gmail_oauth","tenant_smtp","microsoft_tenant"]},"description":"Filtra por tipo de conexión. Un valor fuera de la lista es un 400, no se ignora."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Tamaño de página. Máximo 200."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Las identidades de envío de la cuenta.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","provider","from_address","enabled","is_default","scope"],"properties":{"id":{"type":"string","format":"uuid"},"provider":{"type":"string","enum":["outlook_oauth","gmail_oauth","tenant_smtp","microsoft_tenant"],"description":"`outlook_oauth` / `gmail_oauth` = un buzón conectado por OAuth · `tenant_smtp` = un servidor SMTP propio · `microsoft_tenant` = un buzón de la empresa conectada."},"from_address":{"type":"string","example":"soporte@tuempresa.com","description":"La dirección desde la que sale el correo. Es el valor que usa `POST /api/v1/email/send`."},"from_name":{"type":["string","null"],"example":"Soporte"},"label":{"type":["string","null"],"example":"Buzón de soporte"},"enabled":{"type":"boolean"},"is_default":{"type":"boolean","description":"La identidad que la plataforma elige cuando un envío no nombra remitente."},"scope":{"type":"string","enum":["distributor","member","role"],"description":"`distributor` = compartida por todo el tenant · `member` / `role` = acotada a una persona o a un departamento. **Una identidad acotada no se puede usar desde una API key**: la clave es la cuenta, no esa persona."},"managed_by":{"type":["string","null"],"enum":["amai","tenant",null],"description":"`amai` = la gestionamos nosotros · `tenant` = la conectó el cliente."},"has_credentials":{"type":"boolean","description":"Booleano, y nada más. **Ninguna operación de esta API devuelve una credencial**, ni cifrada, ni parcial, ni enmascarada. `false` significa alta a medias o rotación en curso."},"has_error":{"type":"boolean","description":"`true` cuando el último intento contra el proveedor falló. El texto del error **no se publica**: los mensajes de SMTP y de Graph arrastran el usuario de la cuenta dentro."},"last_verified_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"6f1c4a2e-9b03-4a7d-9f2c-1d5e8b0a7c31","provider":"microsoft_tenant","from_address":"soporte@tuempresa.com","from_name":"Soporte","label":"Microsoft 365","enabled":true,"is_default":true,"scope":"distributor","managed_by":"tenant","has_credentials":true,"has_error":false,"last_verified_at":"2026-07-26T09:12:00Z","created_at":"2026-05-11T10:00:00Z","updated_at":"2026-07-26T09:12:00Z"}],"pagination":{"limit":50,"offset":0,"total":1,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"Consultar tu suscripción de webhooks","description":"Devuelve a dónde se entregan tus eventos y a cuáles estás suscrito.\n\n**Responde `200` con `active: false` cuando no hay nada configurado, no `404`.** «No tengo webhook» es un estado legítimo de un recurso único, no la ausencia de un recurso: un `404` obligaría a toda integración a tratar el caso normal como un error.\n\n**El secreto de firma no viaja aquí, ni en ninguna otra lectura.** Solo `secret_configured`, que dice si lo hay. Un secreto que se puede releer indefinidamente deja de serlo en cuanto alguien emite una clave de solo lectura «para vigilar las entregas».\n\n### Los eventos\n\n**Se entregan de verdad — son los cuatro que se pueden contratar:**\n\n| Evento | Cuándo | Cuerpo |\n|---|---|---|\n| `call.ended` | Una llamada se cerró habiendo facturado segundos | `CallWebhookPayload` |\n| `call.failed` | Una llamada terminó sin `NORMAL_CLEARING` **y** sin segundos facturados | `CallWebhookPayload` |\n| `agent.created` | Se creó un agente (por el asistente o por el constructor conversacional) | `AgentCreatedWebhookPayload` |\n| `whatsapp.message.received` | Entró un mensaje de WhatsApp | `InboundMessage` |\n\nEl cuerpo completo de cada uno, con ejemplos reales, está documentado en la sección **Webhooks** de este mismo contrato. Todos comparten el sobre `{ event, timestamp, data }`.\n\n> `call.ended` y `call.failed` llegan **siempre con `rated: false`**: la tarificación corre > después, así que el evento NO trae el coste. Para el importe, consulta `GET /api/v1/calls` > unos segundos más tarde.\n\n**Estos otros cuatro están en el catálogo y NO los emite nada:** `call.started`, `agent.updated`, `billing.charged` y `billing.low_balance`. No hay ningún punto del sistema que los dispare. **Esta API los rechaza con `event_not_emitted` en vez de aceptarlos**, porque aceptar una suscripción que no va a entregar nunca es peor que no ofrecerla: te quedarías esperando un aviso que no existe, sin forma de enterarte.","operationId":"getWebhookSubscription","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Tu suscripción. `active: false` si no hay ninguna.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["url","events","secret_configured","active"],"properties":{"url":{"type":["string","null"],"example":"https://api.tu-empresa.com/hooks/amai","description":"El destino. `null` si no hay suscripción."},"events":{"type":"array","items":{"type":"string"},"description":"Los eventos contratados. Los eventos retirados que hubiera guardados **no aparecen**: enseñar una suscripción que no entrega es prometer un aviso que no llega.","example":["call.ended","call.failed"]},"secret_configured":{"type":"boolean","description":"Hay secreto de firma. **El valor no se devuelve nunca en una lectura** — se enseña una sola vez, al crearlo. Si lo perdiste, usa `POST` con `rotate_secret: true`."},"active":{"type":"boolean","description":"`false` cuando no hay URL: no se entregará nada."}}}}},"example":{"data":{"url":"https://api.tu-empresa.com/hooks/amai","events":["call.ended","call.failed"],"secret_configured":true,"active":true}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Webhooks"],"summary":"Dar de alta (o reemplazar) tu suscripción de webhooks","description":"Registra el destino al que AMAI te enviará los eventos, y **te devuelve el secreto de firma una sola vez**.\n\n### El secreto se enseña una vez\n\nEs el mismo patrón que las claves de API: `secret` viene en ESTA respuesta y en ninguna otra. Guárdalo donde guardes tus credenciales.\n\nSi ya tenías secreto y no pides rotarlo, vuelve `secret: null` y `secret_rotated: false` — **se conserva el que tu endpoint ya está verificando**. Rotar en silencio al guardar una URL invalidaría todas tus firmas sin avisar, así que la rotación es un acto con nombre: `rotate_secret: true`. Úsalo si lo perdiste, sabiendo que las firmas anteriores dejan de validar en cuanto se emite el nuevo.\n\n### Tu endpoint tiene que ser público, y se comprueba AQUÍ\n\nSe rechaza cualquier URL que apunte —o cuyo dominio **resuelva**— a bucle local, red privada, enlace local o metadatos de nube. Y se comprueba al registrar, no solo al entregar: sin esto podrías registrar `http://localhost/`, recibir un `200 OK` nuestro, y descubrir semanas después que cada entrega se anotó como `blocked_ssrf`. **Un rechazo ahora es un error que se arregla; un bloqueo silencioso en cada entrega es una integración muerta que parece viva.**\n\n### Los eventos\n\n**Se entregan de verdad — son los cuatro que se pueden contratar:**\n\n| Evento | Cuándo | Cuerpo |\n|---|---|---|\n| `call.ended` | Una llamada se cerró habiendo facturado segundos | `CallWebhookPayload` |\n| `call.failed` | Una llamada terminó sin `NORMAL_CLEARING` **y** sin segundos facturados | `CallWebhookPayload` |\n| `agent.created` | Se creó un agente (por el asistente o por el constructor conversacional) | `AgentCreatedWebhookPayload` |\n| `whatsapp.message.received` | Entró un mensaje de WhatsApp | `InboundMessage` |\n\nEl cuerpo completo de cada uno, con ejemplos reales, está documentado en la sección **Webhooks** de este mismo contrato. Todos comparten el sobre `{ event, timestamp, data }`.\n\n> `call.ended` y `call.failed` llegan **siempre con `rated: false`**: la tarificación corre > después, así que el evento NO trae el coste. Para el importe, consulta `GET /api/v1/calls` > unos segundos más tarde.\n\n**Estos otros cuatro están en el catálogo y NO los emite nada:** `call.started`, `agent.updated`, `billing.charged` y `billing.low_balance`. No hay ningún punto del sistema que los dispare. **Esta API los rechaza con `event_not_emitted` en vez de aceptarlos**, porque aceptar una suscripción que no va a entregar nunca es peor que no ofrecerla: te quedarías esperando un aviso que no existe, sin forma de enterarte.\n\n### Cómo verificar la firma\n\nCada entrega llega con `X-AMAI-Signature: sha256=<hmac>`, el HMAC-SHA256 **del cuerpo crudo** con tu secreto de firma.\n\n> **El cuerpo CRUDO, byte a byte.** Es el error que invalida casi todas las primeras > implementaciones: si tu framework parsea el JSON y tú vuelves a serializarlo para firmarlo, > el resultado NO coincide — cambia el orden de las claves, los espacios o el escapado de los > caracteres no ASCII, y el HMAC es otro. En Express necesitas > `express.raw({ type: 'application/json' })`; en Next.js, `await request.text()` **antes** de > `JSON.parse`.\n\n```js\nimport crypto from 'node:crypto'\n\nexport function verifyAmaiSignature(rawBody, header, secret) {\n  if (!header?.startsWith('sha256=')) return false\n  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')\n  const received = header.slice('sha256='.length)\n  // Longitudes distintas: timingSafeEqual LANZA en vez de devolver false.\n  if (received.length !== expected.length) return false\n  // Comparación en tiempo constante: un `===` filtra por cuánto tarda en\n  // fallar cuántos caracteres iniciales acertó quien lo intenta.\n  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))\n}\n```\n\n```js\n// Next.js (App Router)\nexport async function POST(request) {\n  const rawBody = await request.text()               // ← crudo, antes de parsear\n  const signature = request.headers.get('x-amai-signature')\n  if (!verifyAmaiSignature(rawBody, signature, process.env.AMAI_WEBHOOK_SECRET)) {\n    return new Response('invalid signature', { status: 401 })\n  }\n  const { event, timestamp, data } = JSON.parse(rawBody)\n  // …tu lógica. Responde 2xx en menos de 10 s.\n  return new Response('ok')\n}\n```\n\n**Descarta duplicados con `X-AMAI-Delivery`.** Es un identificador único por ENVÍO. Un reintento tras un `5xx` tuyo trae un `X-AMAI-Delivery` distinto y `X-AMAI-Attempt` mayor, así que si tu endpoint respondió 500 después de haber hecho el trabajo, verás el mismo evento dos veces: haz idempotente el procesado usando el identificador que traiga el propio evento (`data.cdr_id`, `data.agent_id`, `data.message_id`).\n\n**Responde rápido y luego trabaja.** El tiempo máximo de espera por intento es de 10 s. Un `4xx` tuyo se interpreta como rechazo definitivo y **corta los reintentos**; un `5xx` o un fallo de red los provoca (hasta 3, con esperas de 1 s, 5 s y 30 s).\n\n### Requisitos de activación\n\nEsta operación pertenece a la superficie de escritura y necesita `PUBLIC_API_WRITE_ENABLED` activo en la plataforma. Mientras esté apagado, la ruta responde `404 not_found`, igual que si no existiera. Además, la cuenta debe tener la capacidad `view_telephony` y la clave el scope `webhooks:write`.","operationId":"createWebhookSubscription","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"example":"https://api.tu-empresa.com/hooks/amai","description":"`http://` o `https://`, accesible desde Internet."},"events":{"type":"array","minItems":1,"items":{"type":"string","enum":["call.ended","call.failed","agent.created","whatsapp.message.received"]},"example":["call.ended","call.failed"]},"rotate_secret":{"type":"boolean","default":false,"description":"Emite un secreto nuevo aunque ya hubiera uno. **Invalida las firmas anteriores.** Sin esto, guardar la configuración nunca cambia tu secreto."}}},"example":{"url":"https://api.tu-empresa.com/hooks/amai","events":["call.ended","call.failed"]}}}},"responses":{"200":{"description":"La suscripción, con el secreto **si se acaba de emitir**. Es la única respuesta de toda la API que lo contiene.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["url","events","active","secret_configured","secret","secret_rotated"],"properties":{"url":{"type":"string"},"events":{"type":"array","items":{"type":"string"}},"active":{"type":"boolean"},"secret_configured":{"type":"boolean"},"secret":{"type":["string","null"],"example":"whsec_3f8a…","description":"**Guárdalo: no hay ninguna lectura que lo devuelva.** `null` cuando ya tenías uno y no pediste rotarlo."},"secret_rotated":{"type":"boolean","description":"`true` si esta llamada emitió un secreto nuevo."}}}}},"example":{"data":{"url":"https://api.tu-empresa.com/hooks/amai","events":["call.ended","call.failed"],"active":true,"secret_configured":true,"secret":"whsec_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef","secret_rotated":true}}}}},"400":{"description":"Cuerpo mal formado, destino no público, o evento que no se entrega.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"event_not_emitted":{"summary":"Un evento del catálogo que nada emite","value":{"error":{"code":"event_not_emitted","message":"Estos eventos existen en el catálogo pero NO los emite nada, así que no se pueden contratar: billing.low_balance. No es un error de tu integración: no hay ningún punto del sistema que los dispare, y suscribirte te dejaría esperando indefinidamente. Eventos que sí se entregan: call.ended, call.failed, agent.created, whatsapp.message.received."}}},"invalid_url":{"summary":"El destino resuelve a una dirección privada","value":{"error":{"code":"invalid_url","message":"No se permiten destinos locales o de red privada. El destino de un webhook debe ser accesible desde Internet: se rechaza cualquier URL que apunte —o que resuelva— a una dirección de bucle local, privada, de enlace local o de metadatos de nube. Se comprueba aquí, al registrar, y otra vez en cada entrega y en cada redirección."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Webhooks"],"summary":"Cambiar el destino o los eventos","description":"Actualización parcial: manda `url`, `events`, o los dos.\n\n**No toca el secreto ni lo devuelve.** Para rotarlo está `POST` con `rotate_secret: true`, que es un acto con nombre y no un efecto colateral de guardar.\n\n`url: null` se rechaza con `400`: vaciar el destino dejaría una suscripción a medias, con eventos contratados y sin a dónde entregarlos. Para darla de baja está `DELETE`, que lo dice en el verbo.\n\n`404` si todavía no hay suscripción: modificar lo que no existe no es una actualización vacía con un `200` delante.\n\n### Requisitos de activación\n\nEsta operación pertenece a la superficie de escritura y necesita `PUBLIC_API_WRITE_ENABLED` activo en la plataforma. Mientras esté apagado, la ruta responde `404 not_found`, igual que si no existiera. Además, la cuenta debe tener la capacidad `view_telephony` y la clave el scope `webhooks:write`.","operationId":"updateWebhookSubscription","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"url":{"type":"string","format":"uri","maxLength":2048},"events":{"type":"array","minItems":1,"items":{"type":"string","enum":["call.ended","call.failed","agent.created","whatsapp.message.received"]}}}},"example":{"events":["call.ended","call.failed","agent.created"]}}}},"responses":{"200":{"description":"La suscripción ya actualizada. Sin el secreto.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["url","events","secret_configured","active"],"properties":{"url":{"type":["string","null"],"example":"https://api.tu-empresa.com/hooks/amai","description":"El destino. `null` si no hay suscripción."},"events":{"type":"array","items":{"type":"string"},"description":"Los eventos contratados. Los eventos retirados que hubiera guardados **no aparecen**: enseñar una suscripción que no entrega es prometer un aviso que no llega.","example":["call.ended","call.failed"]},"secret_configured":{"type":"boolean","description":"Hay secreto de firma. **El valor no se devuelve nunca en una lectura** — se enseña una sola vez, al crearlo. Si lo perdiste, usa `POST` con `rotate_secret: true`."},"active":{"type":"boolean","description":"`false` cuando no hay URL: no se entregará nada."}}}}}}}},"400":{"description":"Cuerpo mal formado, ningún campo editable, `url: null`, destino no público, o evento que no se entrega.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"delete":{"tags":["Webhooks"],"summary":"Dar de baja tu suscripción de webhooks","description":"Quita el destino, los eventos **y el secreto de firma**.\n\nLo del secreto es deliberado. Conservarlo dejaría una trampa: al volver a registrar, el `POST` vería que ya hay uno, devolvería `secret: null`, y te quedarías con una suscripción activa cuyas firmas no puedes verificar y sin saber por qué. Borrándolo, el ciclo queda cerrado: **cada alta emite un secreto y lo enseña**.\n\n**El registro de entregas NO se borra.** Es un histórico inmutable y sigue siendo consultable en `GET /api/v1/webhooks/deliveries`: dar de baja un destino no puede borrar la prueba de lo que se entregó mientras estuvo dado de alta.\n\n### Requisitos de activación\n\nEsta operación pertenece a la superficie de escritura y necesita `PUBLIC_API_WRITE_ENABLED` activo en la plataforma. Mientras esté apagado, la ruta responde `404 not_found`, igual que si no existiera. Además, la cuenta debe tener la capacidad `view_telephony` y la clave el scope `webhooks:write`.","operationId":"deleteWebhookSubscription","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Dada de baja. El histórico de entregas se conserva.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["url","events","secret_configured","active","deliveries_retained"],"properties":{"url":{"type":["string","null"]},"events":{"type":"array","items":{"type":"string"}},"secret_configured":{"type":"boolean"},"active":{"type":"boolean"},"deliveries_retained":{"type":"boolean","description":"Siempre `true`: los intentos ya registrados no se borran."}}}}},"example":{"data":{"url":null,"events":[],"secret_configured":false,"active":false,"deliveries_retained":true}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["Webhooks"],"summary":"Ver si tus entregas llegaron","description":"El registro de intentos de entrega, del más reciente al más antiguo. **Es la operación que convierte los webhooks en algo verificable**: hasta ahora se entregaba a ciegas y no había forma de saber, desde fuera, si tu endpoint estaba recibiendo.\n\n### Un intento por fila\n\nEl registro es inmutable y cada intento —incluidos los reintentos— escribe una fila. Una entrega que acabó bien al tercer intento son **tres filas**, dos con `success: false` y `attempt` 1 y 2. Eso es lo que te deja distinguir «mi endpoint falla» de «mi endpoint tarda pero acaba aceptando», que se arreglan de formas distintas.\n\n### `failure_reason` te dice qué arreglar\n\n| Valor | Qué pasó | Quién lo arregla |\n|---|---|---|\n| `blocked_ssrf` | Tu URL apunta —o resuelve— a una red interna. **No se llegó a hacer la petición.** | Tú: cambia la URL |\n| `signing_secret_undecryptable` | No pudimos descifrar tu secreto y abortamos antes que entregar sin firmar | Nosotros: escríbenos |\n| `timeout` | Tu endpoint no respondió en 10 s | Tú: responde antes y trabaja después |\n| `network_error` | No se pudo conectar (DNS, TLS, conexión) | Tú: comprueba que el destino es alcanzable |\n| `http_error` | Respondió con un código no-2xx | Tú: mira `status_code` |\n\nEs `null` cuando `success` es `true`.\n\nEl cuerpo enviado y la respuesta de tu endpoint **no se publican**: el primero ya lo tienes (y son kilobytes por fila), y el segundo lo generas tú. Lo que no puedes deducir —por qué no salió— es justo lo que sí se publica, destilado.","operationId":"listWebhookDeliveries","security":[{"bearerAuth":[]}],"parameters":[{"name":"event","in":"query","required":false,"schema":{"type":"string"},"description":"Filtra por evento. Admite también los cuatro retirados, porque puede haber filas históricas de cuando se emitían y tu propio histórico tiene que ser consultable.","example":"call.ended"},{"name":"success","in":"query","required":false,"schema":{"type":"boolean"},"description":"`true` solo las que llegaron, `false` solo los fallos. Se aceptan **exactamente** `true` y `false`: un `?success=1` responde `400` en vez de devolver la lista entera haciéndote creer que está filtrada."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Máximo 200 por página."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Los intentos de entrega, del más reciente al más antiguo.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","event","url","success","attempt","delivered_at"],"properties":{"id":{"type":"string","format":"uuid"},"event":{"type":"string","example":"call.ended"},"url":{"type":"string","description":"El destino al que se intentó entregar."},"status_code":{"type":["integer","null"],"description":"El código que devolvió tu endpoint. **`0` cuando no hubo respuesta** (fallo de red, o destino bloqueado); `408` cuando expiró la espera.","example":200},"response_time_ms":{"type":["integer","null"]},"success":{"type":"boolean","description":"Tu endpoint respondió 2xx."},"attempt":{"type":["integer","null"],"description":"Número de intento, empieza en 1. Un reintento es otra fila.","example":1},"delivered_at":{"type":"string","format":"date-time"},"failure_reason":{"type":["string","null"],"enum":["blocked_ssrf","signing_secret_undecryptable","timeout","network_error","http_error",null],"description":"`null` si la entrega llegó. Ver la tabla de arriba."}}}},"pagination":{"type":"object","required":["limit","offset","total"],"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer","description":"Total de intentos que cumplen el filtro."}}}}},"example":{"data":[{"id":"9d1f7c2e-4a3b-4c5d-8e9f-0a1b2c3d4e5f","event":"call.ended","url":"https://api.tu-empresa.com/hooks/amai","status_code":200,"response_time_ms":143,"success":true,"attempt":1,"delivered_at":"2026-07-28T09:12:04.318Z","failure_reason":null},{"id":"1a2b3c4d-5e6f-4708-8192-a3b4c5d6e7f8","event":"call.ended","url":"https://api.tu-empresa.com/hooks/amai","status_code":500,"response_time_ms":87,"success":false,"attempt":1,"delivered_at":"2026-07-28T08:44:51.002Z","failure_reason":"http_error"}],"pagination":{"limit":50,"offset":0,"total":2}}}}},"400":{"description":"Parámetro mal formado. Nunca se ignora en silencio.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/conversations":{"get":{"tags":["Conversations"],"summary":"Listar conversaciones","operationId":"listConversations","x-amai-stability":"beta","x-amai-scope":"whatsapp:read","description":"La bandeja de entrada, paginada **por cursor**.\n\nNo hay `offset` y no es un descuido: una bandeja se reordena mientras la lees (cada mensaje entrante mueve su hilo al principio), así que paginar por posición **se salta hilos y repite otros**, en silencio y con un `200` delante. Un cursor de clave describe «todo lo que va estrictamente detrás de esta fila», así que un hilo que se mueve se ve una vez o ninguna, nunca dos.\n\nTampoco hay `total`: contar un conjunto que se mueve produce un número que ya es falso cuando lo lees. Usa `page.has_more`.","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Tamaño de página. Fuera de rango es `400`, nunca un recorte silencioso."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Copia tal cual `page.next_cursor` de la respuesta anterior. Es opaco: su contenido puede cambiar sin previo aviso y no debe interpretarse."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["open","pending","snoozed","closed","archived"]}},{"name":"automation_mode","in":"query","required":false,"schema":{"type":"string","enum":["off","shadow","copilot","autonomous"]}},{"name":"connection_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Filtra por número conectado. Se **intersecta** con tu tenant; jamás lo sustituye."},{"name":"contact_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."},{"name":"X-AMAI-Sandbox-Now","in":"header","required":false,"schema":{"type":"string","format":"date-time"},"description":"**Solo sandbox** (`CONVERSATIONS_SANDBOX_ENABLED`). Mueve el reloj con el que se calculan los valores DERIVADOS —la ventana de 24 h y si la cesión humana sigue viva— para poder probar el camino «fuera de ventana» sin esperar un día. No afecta a nada que se guarde, se facture, se firme o se autorice. Con el sandbox apagado se ignora."}],"responses":{"200":{"description":"Página de conversaciones.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","page"],"properties":{"data":{"type":"array","items":{"type":"object","required":["conversation_uuid","channel","connection_id","status","revision"],"properties":{"conversation_uuid":{"type":"string","format":"uuid","description":"El identificador del hilo en AMAI. **No es `conversation_id`**: ese nombre pertenece a META y designa su unidad de facturación. Confundirlos rompe la conciliación con Meta."},"channel":{"type":"string","enum":["whatsapp","instagram","messenger","telegram","web","sms","email","voice"]},"connection_id":{"type":"string","format":"uuid","description":"El número/conexión por el que discurre el hilo."},"external_thread_key":{"type":"string","description":"La contraparte, normalizada. Para WhatsApp, el MSISDN en E.164."},"contact_id":{"type":["string","null"],"format":"uuid"},"origin":{"type":"string","enum":["runtime","backfill","import","api"]},"status":{"type":"string","enum":["open","pending","snoozed","closed","archived"]},"automation":{"type":"object","properties":{"mode":{"type":"string","enum":["off","shadow","copilot","autonomous"]},"state":{"type":"string","enum":["idle","thinking","awaiting_tool","awaiting_human","suspended","error"]}}},"assignment":{"type":"object","properties":{"agent_id":{"type":["string","null"],"format":"uuid"},"member_id":{"type":["string","null"],"format":"uuid"},"queue_id":{"type":["string","null"],"format":"uuid"},"assigned_at":{"type":["string","null"],"format":"date-time"}}},"pipeline":{"type":"object","description":"Presente en el contrato desde el principio y **siempre `null` hoy**: el módulo de pipeline (`/pipelines`, `/deals`, `/tasks`) todavía no existe. Se publica vacío en vez de omitirse para que añadirlo no sea un cambio de forma.","properties":{"pipeline_id":{"type":["string","null"],"format":"uuid"},"stage_id":{"type":["string","null"],"format":"uuid"}}},"priority":{"type":"string","enum":["low","normal","high","urgent"]},"language":{"type":["string","null"]},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative","mixed",null]},"last_inbound_at":{"type":["string","null"],"format":"date-time"},"last_outbound_at":{"type":["string","null"],"format":"date-time"},"last_message_at":{"type":["string","null"],"format":"date-time"},"last_human_message_at":{"type":["string","null"],"format":"date-time","description":"Cuándo escribió **una persona** por última vez en este hilo. Lo mantiene un trigger de PostgreSQL (A-020), nunca la aplicación.\n\nEs la forma legible por máquina de «el humano gana siempre»: la regla de envío de la IA **no envía** si hay un mensaje humano más nuevo que el turno que generó el borrador. Un cliente que implemente esa regla necesita este instante; sin él la cláusula `no newer human message` no se puede evaluar desde fuera.\n\n`null` significa **que nadie ha escrito nunca** en este hilo, no que se desconozca."},"customer_window":{"type":"object","required":["expires_at","open"],"properties":{"expires_at":{"type":["string","null"],"format":"date-time"},"open":{"type":"boolean","description":"Derivado. Con la ventana cerrada solo se puede enviar `type=\"template\"`; `type=\"text\"` responde `409 window_expired`."}}},"unread_count":{"type":["integer","null"],"description":"`null` significa **desconocido**, no cero: un hilo reconstruido del histórico no tiene estado de lectura."},"sla_due_at":{"type":["string","null"],"format":"date-time"},"human_lease":{"type":"object","properties":{"owner_id":{"type":["string","null"],"format":"uuid"},"expires_at":{"type":["string","null"],"format":"date-time"},"held":{"type":"boolean"}}},"snoozed_until":{"type":["string","null"],"format":"date-time"},"generation_epoch":{"type":"integer","description":"Se incrementa en cada `takeover`. Un turno de IA que lleve una época anterior es de antes de que entrara el humano y debe descartarse."},"summary":{"type":["string","null"]},"revision":{"type":"integer","description":"Token de concurrencia optimista. Devuélvelo en `If-Match` para no escribir sobre una versión que no has visto."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"}}}},"page":{"type":"object","required":["limit","has_more","next_cursor"],"properties":{"limit":{"type":"integer","example":50},"has_more":{"type":"boolean","description":"Condición de parada del bucle. **Se calcula** leyendo una fila más de las que se devuelven, en las tres listas de esta sección — no se asume por el tamaño de la página. Para de leer cuando sea `false`, y solo entonces."},"next_cursor":{"type":["string","null"],"description":"`null` en la última página. **No hay `total`**: contar un inbox que se mueve da un número ya falso al leerlo."}}}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El recurso no existe **o** la superficie Conversations está apagada en esta instalación (`CONVERSATIONS_API_ENABLED`). Los dos casos responden igual a propósito: con el interruptor apagado, la superficie tiene que ser indistinguible de no estar desplegada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/conversations/{conversation_uuid}":{"get":{"tags":["Conversations"],"summary":"Ver una conversación","operationId":"getConversation","x-amai-stability":"beta","x-amai-scope":"whatsapp:read","description":"El identificador de otro tenant responde `404`, no `403`. Un `403` confirmaría que el id existe, y unas cuantas miles de peticiones convierten eso en un censo de la competencia.","security":[{"bearerAuth":[]}],"parameters":[{"name":"conversation_uuid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."},{"name":"X-AMAI-Sandbox-Now","in":"header","required":false,"schema":{"type":"string","format":"date-time"},"description":"**Solo sandbox** (`CONVERSATIONS_SANDBOX_ENABLED`). Mueve el reloj con el que se calculan los valores DERIVADOS —la ventana de 24 h y si la cesión humana sigue viva— para poder probar el camino «fuera de ventana» sin esperar un día. No afecta a nada que se guarde, se facture, se firme o se autorice. Con el sandbox apagado se ignora."}],"responses":{"200":{"description":"La conversación.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["conversation_uuid","channel","connection_id","status","revision"],"properties":{"conversation_uuid":{"type":"string","format":"uuid","description":"El identificador del hilo en AMAI. **No es `conversation_id`**: ese nombre pertenece a META y designa su unidad de facturación. Confundirlos rompe la conciliación con Meta."},"channel":{"type":"string","enum":["whatsapp","instagram","messenger","telegram","web","sms","email","voice"]},"connection_id":{"type":"string","format":"uuid","description":"El número/conexión por el que discurre el hilo."},"external_thread_key":{"type":"string","description":"La contraparte, normalizada. Para WhatsApp, el MSISDN en E.164."},"contact_id":{"type":["string","null"],"format":"uuid"},"origin":{"type":"string","enum":["runtime","backfill","import","api"]},"status":{"type":"string","enum":["open","pending","snoozed","closed","archived"]},"automation":{"type":"object","properties":{"mode":{"type":"string","enum":["off","shadow","copilot","autonomous"]},"state":{"type":"string","enum":["idle","thinking","awaiting_tool","awaiting_human","suspended","error"]}}},"assignment":{"type":"object","properties":{"agent_id":{"type":["string","null"],"format":"uuid"},"member_id":{"type":["string","null"],"format":"uuid"},"queue_id":{"type":["string","null"],"format":"uuid"},"assigned_at":{"type":["string","null"],"format":"date-time"}}},"pipeline":{"type":"object","description":"Presente en el contrato desde el principio y **siempre `null` hoy**: el módulo de pipeline (`/pipelines`, `/deals`, `/tasks`) todavía no existe. Se publica vacío en vez de omitirse para que añadirlo no sea un cambio de forma.","properties":{"pipeline_id":{"type":["string","null"],"format":"uuid"},"stage_id":{"type":["string","null"],"format":"uuid"}}},"priority":{"type":"string","enum":["low","normal","high","urgent"]},"language":{"type":["string","null"]},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative","mixed",null]},"last_inbound_at":{"type":["string","null"],"format":"date-time"},"last_outbound_at":{"type":["string","null"],"format":"date-time"},"last_message_at":{"type":["string","null"],"format":"date-time"},"last_human_message_at":{"type":["string","null"],"format":"date-time","description":"Cuándo escribió **una persona** por última vez en este hilo. Lo mantiene un trigger de PostgreSQL (A-020), nunca la aplicación.\n\nEs la forma legible por máquina de «el humano gana siempre»: la regla de envío de la IA **no envía** si hay un mensaje humano más nuevo que el turno que generó el borrador. Un cliente que implemente esa regla necesita este instante; sin él la cláusula `no newer human message` no se puede evaluar desde fuera.\n\n`null` significa **que nadie ha escrito nunca** en este hilo, no que se desconozca."},"customer_window":{"type":"object","required":["expires_at","open"],"properties":{"expires_at":{"type":["string","null"],"format":"date-time"},"open":{"type":"boolean","description":"Derivado. Con la ventana cerrada solo se puede enviar `type=\"template\"`; `type=\"text\"` responde `409 window_expired`."}}},"unread_count":{"type":["integer","null"],"description":"`null` significa **desconocido**, no cero: un hilo reconstruido del histórico no tiene estado de lectura."},"sla_due_at":{"type":["string","null"],"format":"date-time"},"human_lease":{"type":"object","properties":{"owner_id":{"type":["string","null"],"format":"uuid"},"expires_at":{"type":["string","null"],"format":"date-time"},"held":{"type":"boolean"}}},"snoozed_until":{"type":["string","null"],"format":"date-time"},"generation_epoch":{"type":"integer","description":"Se incrementa en cada `takeover`. Un turno de IA que lleve una época anterior es de antes de que entrara el humano y debe descartarse."},"summary":{"type":["string","null"]},"revision":{"type":"integer","description":"Token de concurrencia optimista. Devuélvelo en `If-Match` para no escribir sobre una versión que no has visto."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/conversations/{conversation_uuid}/messages":{"get":{"tags":["Conversations"],"summary":"Listar los mensajes de una conversación","operationId":"listConversationMessages","x-amai-stability":"beta","x-amai-scope":"whatsapp:read","description":"Del más reciente al más antiguo.\n\n**Lo que este recurso NO publica, y por qué.** El importe interno del mensaje se queda fuera: son las columnas de las que factura la plataforma y su significado —coste nuestro o precio tuyo— no está decidido en el producto. Publicar un importe ambiguo a la parte contra la que podría ser margen ya produjo aquí una fuga por cuatro vías. El consumo irá en un recurso de facturación con una definición decidida. Tampoco se publican el contenido crudo del proveedor ni las URLs de medios de Meta: son URLs que caducan y que exigen un token de Meta, y la promesa de esta API es que tú nunca manejas uno.","security":[{"bearerAuth":[]}],"parameters":[{"name":"conversation_uuid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Tamaño de página. Fuera de rango es `400`, nunca un recorte silencioso."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Copia tal cual `page.next_cursor` de la respuesta anterior. Es opaco: su contenido puede cambiar sin previo aviso y no debe interpretarse."},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."}],"responses":{"200":{"description":"Página de mensajes.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","page"],"properties":{"data":{"type":"array","items":{"type":"object","required":["message_uuid","direction","type","status"],"properties":{"message_uuid":{"type":"string","format":"uuid"},"conversation_uuid":{"type":["string","null"],"format":"uuid"},"direction":{"type":"string","enum":["inbound","outbound"]},"type":{"type":"string","enum":["text","template","image","audio","video","document","interactive","reaction","location","contacts","sticker"]},"body":{"type":["string","null"]},"status":{"type":["string","null"],"enum":["pending","sent","delivered","read","failed",null],"description":"La etiqueta actual. Las FECHAS de `lifecycle` son la evidencia, y son lo único que sobrevive a dos acuses que llegan desordenados."},"lifecycle":{"type":"object","properties":{"accepted_at":{"type":["string","null"],"format":"date-time","description":"Cuándo lo aceptó el proveedor. `null` mientras no haya salido."},"sent_at":{"type":["string","null"],"format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"},"read_at":{"type":["string","null"],"format":"date-time"},"failed_at":{"type":["string","null"],"format":"date-time"}}},"provider":{"type":"object","properties":{"message_id":{"type":["string","null"],"description":"El WAMID de Meta cuando existe."},"status":{"type":["string","null"]},"status_at":{"type":["string","null"],"format":"date-time"}}},"from":{"type":"string"},"to":{"type":"string"},"contact_name":{"type":["string","null"]},"template":{"type":["object","null"],"properties":{"name":{"type":"string"},"language":{"type":"string"}}},"reply_to":{"type":["object","null"],"properties":{"message_uuid":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]}}},"sender":{"type":"object","properties":{"actor_type":{"type":["string","null"],"enum":["contact","human","ai","system","api","automation",null]},"actor_id":{"type":["string","null"],"format":"uuid"}}},"error":{"type":["object","null"],"properties":{"code":{"type":["string","null"]},"message":{"type":["string","null"]}}},"idempotency_key":{"type":["string","null"]},"correlation_id":{"type":["string","null"],"format":"uuid"},"revision":{"type":"integer"},"occurred_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}}},"page":{"type":"object","required":["limit","has_more","next_cursor"],"properties":{"limit":{"type":"integer","example":50},"has_more":{"type":"boolean","description":"Condición de parada del bucle. **Se calcula** leyendo una fila más de las que se devuelven, en las tres listas de esta sección — no se asume por el tamaño de la página. Para de leer cuando sea `false`, y solo entonces."},"next_cursor":{"type":["string","null"],"description":"`null` en la última página. **No hay `total`**: contar un inbox que se mueve da un número ya falso al leerlo."}}}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}},"post":{"tags":["Conversations"],"summary":"Responder en una conversación","operationId":"createConversationMessage","x-amai-stability":"beta","x-amai-scope":"whatsapp:send","description":"**Registra la intención de enviar. No envía.**\n\nLa llamada escribe una fila de mensaje saliente (`status: \"pending\"`, `lifecycle.accepted_at: null`) y responde `202`. La llamada al proveedor la hace la pasarela, no este endpoint. El orden es una regla de producto: *ningún mensaje sale sin una intención durable previa*. La ruta antigua hace lo contrario —llama a Meta primero y se traga el error de su propio `INSERT`—, con lo que puede responder «enviado» a un mensaje que existe en Meta y en ningún sitio nuestro: dinero gastado, nada que conciliar y nada que reintentar.\n\n⚠ **Mientras no haya un despachador drenando `status=\"pending\"`, el mensaje se queda ahí y no se entrega.** Se dice aquí porque un endpoint que acepta un envío y no hace nada es peor que un endpoint que no existe. La forma legible por máquina del mismo hecho es la respuesta: `status: \"pending\"` con `lifecycle.accepted_at: null`.\n\n**`Idempotency-Key` es obligatoria.** Un reintento sin ella es un segundo mensaje real y un segundo cargo real, y «el cliente debería mandarla» no es un control. La unicidad la impone PostgreSQL, así que dos reintentos simultáneos no pueden ganar los dos.\n\nMisma clave y misma petición → `200` con el mensaje original. Misma clave y petición distinta → `409 idempotency_conflict`: devolver el mensaje viejo haría desaparecer el segundo con un código de éxito.\n\n**Esta operación solo acepta API key.** La cookie de sesión no autoriza escrituras aquí; un componente embebido usa un token de sesión de corta duración y con alcance, nunca una API key en el navegador.","security":[{"bearerAuth":[]}],"parameters":[{"name":"conversation_uuid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","minLength":8,"maxLength":255},"description":"Única por intención de envío. Reutilizarla con el MISMO cuerpo es un reintento seguro."},{"name":"If-Match","in":"header","required":false,"schema":{"type":"string"},"description":"El `revision` que leíste. Si la conversación ha cambiado desde entonces recibes `409 revision_conflict`."},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."},{"name":"X-AMAI-Sandbox-Now","in":"header","required":false,"schema":{"type":"string","format":"date-time"},"description":"**Solo sandbox** (`CONVERSATIONS_SANDBOX_ENABLED`). Mueve el reloj con el que se calculan los valores DERIVADOS —la ventana de 24 h y si la cesión humana sigue viva— para poder probar el camino «fuera de ventana» sin esperar un día. No afecta a nada que se guarde, se facture, se firme o se autorice. Con el sandbox apagado se ignora."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type"],"properties":{"type":{"type":"string","enum":["text","template"]},"body":{"type":"string","maxLength":4096,"description":"Obligatorio con `type=\"text\"`."},"template":{"type":"object","required":["name","language"],"properties":{"name":{"type":"string"},"language":{"type":"string","example":"es"}},"description":"Obligatorio con `type=\"template\"`. Es lo ÚNICO que se puede enviar fuera de la ventana de 24 h."}}},"examples":{"texto":{"summary":"Texto dentro de la ventana","value":{"type":"text","body":"Le llamamos en 10 minutos."}},"plantilla":{"summary":"Plantilla fuera de la ventana","value":{"type":"template","template":{"name":"recordatorio_cita","language":"es"}}}}}}},"responses":{"200":{"description":"Reintento con la misma `Idempotency-Key` y el mismo cuerpo: el mensaje original.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["message_uuid","direction","type","status"],"properties":{"message_uuid":{"type":"string","format":"uuid"},"conversation_uuid":{"type":["string","null"],"format":"uuid"},"direction":{"type":"string","enum":["inbound","outbound"]},"type":{"type":"string","enum":["text","template","image","audio","video","document","interactive","reaction","location","contacts","sticker"]},"body":{"type":["string","null"]},"status":{"type":["string","null"],"enum":["pending","sent","delivered","read","failed",null],"description":"La etiqueta actual. Las FECHAS de `lifecycle` son la evidencia, y son lo único que sobrevive a dos acuses que llegan desordenados."},"lifecycle":{"type":"object","properties":{"accepted_at":{"type":["string","null"],"format":"date-time","description":"Cuándo lo aceptó el proveedor. `null` mientras no haya salido."},"sent_at":{"type":["string","null"],"format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"},"read_at":{"type":["string","null"],"format":"date-time"},"failed_at":{"type":["string","null"],"format":"date-time"}}},"provider":{"type":"object","properties":{"message_id":{"type":["string","null"],"description":"El WAMID de Meta cuando existe."},"status":{"type":["string","null"]},"status_at":{"type":["string","null"],"format":"date-time"}}},"from":{"type":"string"},"to":{"type":"string"},"contact_name":{"type":["string","null"]},"template":{"type":["object","null"],"properties":{"name":{"type":"string"},"language":{"type":"string"}}},"reply_to":{"type":["object","null"],"properties":{"message_uuid":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]}}},"sender":{"type":"object","properties":{"actor_type":{"type":["string","null"],"enum":["contact","human","ai","system","api","automation",null]},"actor_id":{"type":["string","null"],"format":"uuid"}}},"error":{"type":["object","null"],"properties":{"code":{"type":["string","null"]},"message":{"type":["string","null"]}}},"idempotency_key":{"type":["string","null"]},"correlation_id":{"type":["string","null"],"format":"uuid"},"revision":{"type":"integer"},"occurred_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}}}}},"202":{"description":"Intención registrada. Todavía **no** se ha enviado.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["message_uuid","direction","type","status"],"properties":{"message_uuid":{"type":"string","format":"uuid"},"conversation_uuid":{"type":["string","null"],"format":"uuid"},"direction":{"type":"string","enum":["inbound","outbound"]},"type":{"type":"string","enum":["text","template","image","audio","video","document","interactive","reaction","location","contacts","sticker"]},"body":{"type":["string","null"]},"status":{"type":["string","null"],"enum":["pending","sent","delivered","read","failed",null],"description":"La etiqueta actual. Las FECHAS de `lifecycle` son la evidencia, y son lo único que sobrevive a dos acuses que llegan desordenados."},"lifecycle":{"type":"object","properties":{"accepted_at":{"type":["string","null"],"format":"date-time","description":"Cuándo lo aceptó el proveedor. `null` mientras no haya salido."},"sent_at":{"type":["string","null"],"format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"},"read_at":{"type":["string","null"],"format":"date-time"},"failed_at":{"type":["string","null"],"format":"date-time"}}},"provider":{"type":"object","properties":{"message_id":{"type":["string","null"],"description":"El WAMID de Meta cuando existe."},"status":{"type":["string","null"]},"status_at":{"type":["string","null"],"format":"date-time"}}},"from":{"type":"string"},"to":{"type":"string"},"contact_name":{"type":["string","null"]},"template":{"type":["object","null"],"properties":{"name":{"type":"string"},"language":{"type":"string"}}},"reply_to":{"type":["object","null"],"properties":{"message_uuid":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]}}},"sender":{"type":"object","properties":{"actor_type":{"type":["string","null"],"enum":["contact","human","ai","system","api","automation",null]},"actor_id":{"type":["string","null"],"format":"uuid"}}},"error":{"type":["object","null"],"properties":{"code":{"type":["string","null"]},"message":{"type":["string","null"]}}},"idempotency_key":{"type":["string","null"]},"correlation_id":{"type":["string","null"],"format":"uuid"},"revision":{"type":"integer"},"occurred_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"409":{"description":"Conflicto de estado. Cuatro causas distinguibles por `code`: `window_expired` (fuera de la ventana de 24 h, usa una plantilla), `conversation_closed`, `idempotency_conflict` (misma clave, cuerpo distinto) y `revision_conflict` (`If-Match` desfasado).","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"examples":{"window_expired":{"summary":"Fuera de la ventana de 24 h","value":{"error":{"code":"window_expired","message":"La ventana de 24 h de atención al cliente está cerrada. Fuera de ella solo se puede enviar una plantilla aprobada (type=\"template\")."}}},"idempotency_conflict":{"summary":"Misma clave, cuerpo distinto","value":{"error":{"code":"idempotency_conflict","message":"Ya se usó esa \"Idempotency-Key\" para una petición distinta. Usa una clave nueva."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/whatsapp/connections":{"get":{"tags":["Conversations"],"summary":"Listar los WhatsApp conectados","operationId":"listWhatsappConnections","x-amai-stability":"beta","x-amai-scope":"whatsapp:read","description":"Los números que tu cuenta tiene conectados, **sin entrar nunca en Meta**.\n\nNo devuelve —ni devolverá— el token de acceso ni el token de verificación del webhook. AMAI los custodia y los rota; que tú no los manejes es la promesa central de esta API.\n\nOrdenado por `routing_priority` ascendente (menor gana) y, a igualdad, por identificador. Se pagina **por cursor**, igual que el resto de la cápsula: `page.has_more` se calcula leyendo una fila de más, así que dice la verdad también con `?limit=1`. El `cursor` de esta lista **no es intercambiable** con el de la bandeja: usar uno en la otra responde `400`, nunca una página medida desde una frontera que allí no significa nada.","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Tamaño de página. Fuera de rango es `400`, nunca un recorte silencioso."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Copia tal cual `page.next_cursor` de la respuesta anterior. Es opaco: su contenido puede cambiar sin previo aviso y no debe interpretarse."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","active","suspended","disconnected","error"]}},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."}],"responses":{"200":{"description":"Los números conectados.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","page"],"properties":{"data":{"type":"array","items":{"type":"object","required":["connection_id","channel","provider","waba_id","phone_number_id","status"],"properties":{"connection_id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp"]},"provider":{"type":"string","enum":["meta_cloud"]},"waba_id":{"type":"string"},"phone_number_id":{"type":"string","description":"El identificador de Meta para el número. Se publica para que puedas conciliar con tus propios registros; **nunca se acepta como entrada**: el tenant sale siempre de la credencial, jamás de un dato que venga del proveedor o de la petición."},"display_phone_number":{"type":"string"},"verified_name":{"type":["string","null"]},"display_name":{"type":["string","null"]},"status":{"type":"string","enum":["pending","active","suspended","disconnected","error"]},"messaging_limit":{"type":["string","null"],"enum":["TIER_250","TIER_1K","TIER_10K","TIER_100K","UNLIMITED",null]},"quality_rating":{"type":["string","null"],"enum":["GREEN","YELLOW","RED",null]},"is_default":{"type":"boolean"},"timezone":{"type":["string","null"]},"routing_priority":{"type":["integer","null"]},"health":{"type":"object","properties":{"status":{"type":["string","null"]},"checked_at":{"type":["string","null"],"format":"date-time"},"last_webhook_at":{"type":["string","null"],"format":"date-time"}}},"capabilities":{"type":"object","properties":{"coexistence":{"type":"boolean"},"calling":{"type":"boolean"}}},"connected_at":{"type":["string","null"],"format":"date-time"},"disconnected_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"page":{"type":"object","required":["limit","has_more","next_cursor"],"properties":{"limit":{"type":"integer","example":50},"has_more":{"type":"boolean","description":"Condición de parada del bucle. **Se calcula** leyendo una fila más de las que se devuelven, en las tres listas de esta sección — no se asume por el tamaño de la página. Para de leer cuando sea `false`, y solo entonces."},"next_cursor":{"type":["string","null"],"description":"`null` en la última página. **No hay `total`**: contar un inbox que se mueve da un número ya falso al leerlo."}}}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El recurso no existe **o** la superficie Conversations está apagada en esta instalación (`CONVERSATIONS_API_ENABLED`). Los dos casos responden igual a propósito: con el interruptor apagado, la superficie tiene que ser indistinguible de no estar desplegada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/events/catalog":{"get":{"tags":["Conversations"],"summary":"Catálogo de eventos","operationId":"getEventCatalog","x-amai-stability":"beta","x-amai-scope":"webhooks:read","description":"Todos los eventos del producto, **con su estado real**: `live` significa que hay código que lo emite; `planned` significa que no lo hay y que no llegará nada.\n\nSe publica como datos y no como documentación a propósito. La lista completa son ~22 eventos repartidos entre cinco equipos y la mayoría no tiene todavía quien los emita; publicarlos como webhooks del contrato repetiría un defecto que este producto ya tuvo (el panel ofrecía cuatro avisos que nadie disparaba, y quien los activaba esperaba para siempre). Escribe tu despachador una vez contra este catálogo en vez de rehacerlo cada vez que sale un evento nuevo.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."}],"responses":{"200":{"description":"El catálogo, con el esquema de firma y la política de reintentos.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["api_version","signature","retries","data"],"properties":{"api_version":{"type":"string","example":"2026-07-31"},"signature":{"type":"object","properties":{"v1_header":{"type":"string","example":"X-AMAI-Signature"},"v2_header":{"type":"string","example":"X-AMAI-Signature-V2"},"timestamp_header":{"type":"string","example":"X-AMAI-Timestamp"},"tolerance_seconds":{"type":"integer","example":300},"delivery_header":{"type":"string","example":"X-AMAI-Delivery"},"algorithm":{"type":"string","example":"hmac-sha256"}}},"retries":{"type":"object","properties":{"max_attempts":{"type":"integer","example":4},"backoff_ms":{"type":"array","items":{"type":"integer"},"example":[1000,5000,30000]},"timeout_ms":{"type":"integer","example":10000}}},"data":{"type":"array","items":{"type":"object","required":["event","version","status"],"properties":{"event":{"type":"string","example":"conversation.created"},"version":{"type":"integer","example":1},"status":{"type":"string","enum":["live","planned"]},"owner":{"type":"string"},"description":{"type":"string"}}}}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El recurso no existe **o** la superficie Conversations está apagada en esta instalación (`CONVERSATIONS_API_ENABLED`). Los dos casos responden igual a propósito: con el interruptor apagado, la superficie tiene que ser indistinguible de no estar desplegada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/conversations/{conversation_uuid}/takeover":{"post":{"tags":["Conversations"],"summary":"Tomar el control (takeover humano)","operationId":"takeoverConversation","x-amai-stability":"beta","x-amai-scope":"whatsapp:send","description":"El humano gana siempre, y por eso esto **no es un booleano**.\n\nUn booleano pierde justo la carrera para la que existe: dos personas pulsan a la vez, las dos leen `false`, las dos escriben `true` y las dos escriben en el mismo hilo. La mutación aquí es un solo `UPDATE` con tres condiciones (es tuya · sigue en la versión que leíste · nadie tiene la cesión viva), así que la decide PostgreSQL: quien pierde afecta a **cero filas** y recibe `409 lease_held`.\n\nEn la misma sentencia se incrementa `generation_epoch`. Cualquier turno de IA que llevara una época anterior es, por definición, de antes de que entrara el humano, y quien lo termine debe descartarlo: así se cancela la generación pendiente con un número en vez de con un aviso que se puede perder.\n\n`owner_user_id` es obligatorio: la cesión la sostiene una PERSONA, y una clave de API no lo es. Se comprueba que ese usuario pertenece a tu cuenta.","security":[{"bearerAuth":[]}],"parameters":[{"name":"conversation_uuid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."},{"name":"X-AMAI-Sandbox-Now","in":"header","required":false,"schema":{"type":"string","format":"date-time"},"description":"**Solo sandbox** (`CONVERSATIONS_SANDBOX_ENABLED`). Mueve el reloj con el que se calculan los valores DERIVADOS —la ventana de 24 h y si la cesión humana sigue viva— para poder probar el camino «fuera de ventana» sin esperar un día. No afecta a nada que se guarde, se facture, se firme o se autorice. Con el sandbox apagado se ignora."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_user_id"],"properties":{"owner_user_id":{"type":"string","format":"uuid","description":"El usuario que toma el control. Debe pertenecer a tu cuenta."},"ttl_seconds":{"type":"integer","minimum":60,"maximum":3600,"default":900,"description":"Cuánto dura la cesión. Al caducar, otra persona puede tomarla."}}}}}},"responses":{"200":{"description":"La conversación, con la cesión tomada y la época incrementada.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["conversation_uuid","channel","connection_id","status","revision"],"properties":{"conversation_uuid":{"type":"string","format":"uuid","description":"El identificador del hilo en AMAI. **No es `conversation_id`**: ese nombre pertenece a META y designa su unidad de facturación. Confundirlos rompe la conciliación con Meta."},"channel":{"type":"string","enum":["whatsapp","instagram","messenger","telegram","web","sms","email","voice"]},"connection_id":{"type":"string","format":"uuid","description":"El número/conexión por el que discurre el hilo."},"external_thread_key":{"type":"string","description":"La contraparte, normalizada. Para WhatsApp, el MSISDN en E.164."},"contact_id":{"type":["string","null"],"format":"uuid"},"origin":{"type":"string","enum":["runtime","backfill","import","api"]},"status":{"type":"string","enum":["open","pending","snoozed","closed","archived"]},"automation":{"type":"object","properties":{"mode":{"type":"string","enum":["off","shadow","copilot","autonomous"]},"state":{"type":"string","enum":["idle","thinking","awaiting_tool","awaiting_human","suspended","error"]}}},"assignment":{"type":"object","properties":{"agent_id":{"type":["string","null"],"format":"uuid"},"member_id":{"type":["string","null"],"format":"uuid"},"queue_id":{"type":["string","null"],"format":"uuid"},"assigned_at":{"type":["string","null"],"format":"date-time"}}},"pipeline":{"type":"object","description":"Presente en el contrato desde el principio y **siempre `null` hoy**: el módulo de pipeline (`/pipelines`, `/deals`, `/tasks`) todavía no existe. Se publica vacío en vez de omitirse para que añadirlo no sea un cambio de forma.","properties":{"pipeline_id":{"type":["string","null"],"format":"uuid"},"stage_id":{"type":["string","null"],"format":"uuid"}}},"priority":{"type":"string","enum":["low","normal","high","urgent"]},"language":{"type":["string","null"]},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative","mixed",null]},"last_inbound_at":{"type":["string","null"],"format":"date-time"},"last_outbound_at":{"type":["string","null"],"format":"date-time"},"last_message_at":{"type":["string","null"],"format":"date-time"},"last_human_message_at":{"type":["string","null"],"format":"date-time","description":"Cuándo escribió **una persona** por última vez en este hilo. Lo mantiene un trigger de PostgreSQL (A-020), nunca la aplicación.\n\nEs la forma legible por máquina de «el humano gana siempre»: la regla de envío de la IA **no envía** si hay un mensaje humano más nuevo que el turno que generó el borrador. Un cliente que implemente esa regla necesita este instante; sin él la cláusula `no newer human message` no se puede evaluar desde fuera.\n\n`null` significa **que nadie ha escrito nunca** en este hilo, no que se desconozca."},"customer_window":{"type":"object","required":["expires_at","open"],"properties":{"expires_at":{"type":["string","null"],"format":"date-time"},"open":{"type":"boolean","description":"Derivado. Con la ventana cerrada solo se puede enviar `type=\"template\"`; `type=\"text\"` responde `409 window_expired`."}}},"unread_count":{"type":["integer","null"],"description":"`null` significa **desconocido**, no cero: un hilo reconstruido del histórico no tiene estado de lectura."},"sla_due_at":{"type":["string","null"],"format":"date-time"},"human_lease":{"type":"object","properties":{"owner_id":{"type":["string","null"],"format":"uuid"},"expires_at":{"type":["string","null"],"format":"date-time"},"held":{"type":"boolean"}}},"snoozed_until":{"type":["string","null"],"format":"date-time"},"generation_epoch":{"type":"integer","description":"Se incrementa en cada `takeover`. Un turno de IA que lleve una época anterior es de antes de que entrara el humano y debe descartarse."},"summary":{"type":["string","null"]},"revision":{"type":"integer","description":"Token de concurrencia optimista. Devuélvelo en `If-Match` para no escribir sobre una versión que no has visto."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"409":{"description":"Otra persona tiene la cesión viva, o la conversación cambió desde que la leíste. Las dos se arreglan igual —volver a leer y decidir— así que comparten código.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"lease_held","message":"Otra persona tiene el control de esta conversación, o ha cambiado desde que la leíste. Vuelve a leerla y decide de nuevo."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/conversations/{conversation_uuid}/resume":{"post":{"tags":["Conversations"],"summary":"Devolver el control","operationId":"resumeConversation","x-amai-stability":"beta","x-amai-scope":"whatsapp:send","description":"Solo quien tiene la cesión puede soltarla: la sentencia está condicionada a `human_lease_owner_id = owner_user_id`, así que otro agente afecta a cero filas y recibe `409 lease_not_held`.\n\n`generation_epoch` **no** se incrementa aquí, a propósito: su función es invalidar turnos de IA anteriores a la intervención humana, y devolver el control no invalida nada.\n\n`automation_mode` es opcional. Si no lo mandas, el modo no se toca: encender el agente de alguien por el mero hecho de soltar el teclado sería una sorpresa cara.","security":[{"bearerAuth":[]}],"parameters":[{"name":"conversation_uuid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."},{"name":"X-AMAI-Sandbox-Now","in":"header","required":false,"schema":{"type":"string","format":"date-time"},"description":"**Solo sandbox** (`CONVERSATIONS_SANDBOX_ENABLED`). Mueve el reloj con el que se calculan los valores DERIVADOS —la ventana de 24 h y si la cesión humana sigue viva— para poder probar el camino «fuera de ventana» sin esperar un día. No afecta a nada que se guarde, se facture, se firme o se autorice. Con el sandbox apagado se ignora."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_user_id"],"properties":{"owner_user_id":{"type":"string","format":"uuid"},"automation_mode":{"type":"string","enum":["off","shadow","copilot","autonomous"]}}}}}},"responses":{"200":{"description":"La conversación, con la cesión liberada.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["conversation_uuid","channel","connection_id","status","revision"],"properties":{"conversation_uuid":{"type":"string","format":"uuid","description":"El identificador del hilo en AMAI. **No es `conversation_id`**: ese nombre pertenece a META y designa su unidad de facturación. Confundirlos rompe la conciliación con Meta."},"channel":{"type":"string","enum":["whatsapp","instagram","messenger","telegram","web","sms","email","voice"]},"connection_id":{"type":"string","format":"uuid","description":"El número/conexión por el que discurre el hilo."},"external_thread_key":{"type":"string","description":"La contraparte, normalizada. Para WhatsApp, el MSISDN en E.164."},"contact_id":{"type":["string","null"],"format":"uuid"},"origin":{"type":"string","enum":["runtime","backfill","import","api"]},"status":{"type":"string","enum":["open","pending","snoozed","closed","archived"]},"automation":{"type":"object","properties":{"mode":{"type":"string","enum":["off","shadow","copilot","autonomous"]},"state":{"type":"string","enum":["idle","thinking","awaiting_tool","awaiting_human","suspended","error"]}}},"assignment":{"type":"object","properties":{"agent_id":{"type":["string","null"],"format":"uuid"},"member_id":{"type":["string","null"],"format":"uuid"},"queue_id":{"type":["string","null"],"format":"uuid"},"assigned_at":{"type":["string","null"],"format":"date-time"}}},"pipeline":{"type":"object","description":"Presente en el contrato desde el principio y **siempre `null` hoy**: el módulo de pipeline (`/pipelines`, `/deals`, `/tasks`) todavía no existe. Se publica vacío en vez de omitirse para que añadirlo no sea un cambio de forma.","properties":{"pipeline_id":{"type":["string","null"],"format":"uuid"},"stage_id":{"type":["string","null"],"format":"uuid"}}},"priority":{"type":"string","enum":["low","normal","high","urgent"]},"language":{"type":["string","null"]},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative","mixed",null]},"last_inbound_at":{"type":["string","null"],"format":"date-time"},"last_outbound_at":{"type":["string","null"],"format":"date-time"},"last_message_at":{"type":["string","null"],"format":"date-time"},"last_human_message_at":{"type":["string","null"],"format":"date-time","description":"Cuándo escribió **una persona** por última vez en este hilo. Lo mantiene un trigger de PostgreSQL (A-020), nunca la aplicación.\n\nEs la forma legible por máquina de «el humano gana siempre»: la regla de envío de la IA **no envía** si hay un mensaje humano más nuevo que el turno que generó el borrador. Un cliente que implemente esa regla necesita este instante; sin él la cláusula `no newer human message` no se puede evaluar desde fuera.\n\n`null` significa **que nadie ha escrito nunca** en este hilo, no que se desconozca."},"customer_window":{"type":"object","required":["expires_at","open"],"properties":{"expires_at":{"type":["string","null"],"format":"date-time"},"open":{"type":"boolean","description":"Derivado. Con la ventana cerrada solo se puede enviar `type=\"template\"`; `type=\"text\"` responde `409 window_expired`."}}},"unread_count":{"type":["integer","null"],"description":"`null` significa **desconocido**, no cero: un hilo reconstruido del histórico no tiene estado de lectura."},"sla_due_at":{"type":["string","null"],"format":"date-time"},"human_lease":{"type":"object","properties":{"owner_id":{"type":["string","null"],"format":"uuid"},"expires_at":{"type":["string","null"],"format":"date-time"},"held":{"type":"boolean"}}},"snoozed_until":{"type":["string","null"],"format":"date-time"},"generation_epoch":{"type":"integer","description":"Se incrementa en cada `takeover`. Un turno de IA que lleve una época anterior es de antes de que entrara el humano y debe descartarse."},"summary":{"type":["string","null"]},"revision":{"type":"integer","description":"Token de concurrencia optimista. Devuélvelo en `If-Match` para no escribir sobre una versión que no has visto."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"409":{"description":"Ese usuario no tiene la cesión.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"lease_not_held","message":"Ese usuario no tiene el control de esta conversación, así que no puede devolverlo."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/events/replay":{"post":{"tags":["Conversations"],"summary":"Reenviar una entrega de webhook","operationId":"replayEventDelivery","x-amai-stability":"beta","x-amai-scope":"webhooks:write","description":"Vuelve a enviar un evento que ya ocurrió. Hasta ahora `/api/v1/webhooks/deliveries` solo tenía `GET`: podías **ver** las cuatro entregas que fallaron mientras tu servidor estuvo caído y no había forma de pedirlas otra vez.\n\n**Reenviar no duplica el efecto.** El reenvío lleva el **mismo** `X-AMAI-Delivery` que el original, y el contrato siempre te ha dicho que deduplicaras por esa cabecera; si lo haces, el efecto se aplica una sola vez por muchas veces que se reenvíe. Lo que **no** se deduplica es la LLAMADA: cada reenvío es un intento real con sus filas en el registro. Es deliberado — quien pulsa dos veces quiere ver dos intentos, y la protección que importa está donde está el efecto, en tu receptor.\n\nDos cosas que tú **no** controlas: el cuerpo (sale de la fila guardada, no de tu petición) y el destino (sale de la configuración ACTUAL de tu cuenta, no de la URL que la entrega usó en su día — cambiar de URL es precisamente lo que se hace cuando un endpoint se ha visto comprometido).\n\nPuede tardar hasta ~36 s si tu endpoint no responde: se ejecuta la política de reintentos completa antes de contestar, para poder decirte lo que pasó de verdad en vez de un `202` optimista.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"UUID propio para poder localizar después esta petición y los eventos que genere. Si envías algo que no sea un UUID recibes un `400`: se guarda en una columna `uuid`, y aceptarlo para luego no guardarlo sería perderlo en silencio."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["delivery_id"],"properties":{"delivery_id":{"type":"string","format":"uuid","description":"El `id` que devuelve `GET /api/v1/webhooks/deliveries` para la fila cuyo `attempt` es **1**. Solo esa fila lleva el identificador de entrega; reenviar desde una fila de reintento mandaría un `X-AMAI-Delivery` distinto y la propiedad de «no duplica» dejaría de cumplirse en silencio."}}}}}},"responses":{"200":{"description":"Resultado real del reenvío.","headers":{"X-Correlation-Id":{"description":"El identificador con el que se puede localizar después esta petición y los eventos que provocó. Si lo envías tú (debe ser un UUID) se respeta y se guarda junto al evento.","schema":{"type":"string","format":"uuid"}},"X-Request-Id":{"description":"Identifica ESTA llamada HTTP. Siempre lo generamos nosotros.","schema":{"type":"string","format":"uuid"}},"X-RateLimit-Limit":{"description":"Peticiones por ventana, cobradas a tu clave.","schema":{"type":"integer","example":60}},"X-RateLimit-Policy":{"description":"`60;w=60`. **`X-RateLimit-Remaining` no se emite**: la guarda compartida consume el limitador y descarta el resto, y publicar una estimación sería una cabecera que miente.","schema":{"type":"string","example":"60;w=60"}}},"content":{"application/json":{"schema":{"type":"object","required":["delivery_id","event","replayed","delivered","attempts"],"properties":{"delivery_id":{"type":"string","format":"uuid","description":"El MISMO id con el que salió el original. Deduplica por él."},"event":{"type":"string"},"replayed":{"type":"boolean"},"delivered":{"type":"boolean"},"attempts":{"type":"integer"},"last_status_code":{"type":"integer"}}}}}},"400":{"description":"Parámetro o cuerpo inválido. Un filtro mal escrito **nunca** se ignora: ignorarlo devolvería un conjunto distinto del que pediste, con un `200` delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"invalid_field","message":"\"status\" debe ser uno de: open, pending, snoozed, closed, archived."}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"409":{"description":"No hay a dónde reenviar: la cuenta no tiene URL de webhook, ya no está suscrita a ese evento, o la fila no conserva el cuerpo original.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","insufficient_scope","capability_required","rate_limited","invalid_json","invalid_body","invalid_field","invalid_cursor","invalid_range","invalid_date","missing_idempotency_key","not_found","conflict","idempotency_conflict","revision_conflict","window_expired","conversation_closed","lease_held","lease_not_held","consent_required","internal_error","not_implemented"],"description":"Slug estable. La lista es CERRADA: cualquier código nuevo llega con una versión nueva del documento y una entrada en el changelog."},"message":{"type":"string","description":"Texto para un humano. No lo parsees."}}}}},"example":{"error":{"code":"conflict","message":"No hay ninguna URL de webhook configurada en la cuenta. Configúrala antes de reenviar."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}},"x-amai-availability":{"status":"flagged","flag":"CONVERSATIONS_API_ENABLED","disabled_response":404,"note":"Publicada siempre para que el documento sea determinista, pero SERVIDA solo con `CONVERSATIONS_API_ENABLED` activo. Apagado responde 404 con el mismo cuerpo que un recurso inexistente, a propósito: un prober no debe poder distinguir «apagada» de «no existe»."}}},"/api/v1/contacts":{"get":{"tags":["Contactos"],"summary":"Listar contactos","description":"Enumera el CRM del tenant, con paginación obligatoria por tamaño de página.\n\nUn parámetro presente y mal formado devuelve **400**, nunca un valor por defecto: un `?status=cualificado` ignorado devolvería la lista ENTERA con un 200 delante y creerías estar viendo un subconjunto filtrado.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `contacts:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`. Si tu clave está en modo «acceso completo», pídele al distribuidor que le marque «Listar y buscar contactos».","operationId":"listContacts","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."},{"name":"status","in":"query","schema":{"type":"string","enum":["new","contacted","interested","qualified","callback","no_answer","voicemail","not_interested","converted","disqualified","do_not_call","invalid"]},"description":"Filtra por estado. Un valor fuera del enum es 400."},{"name":"type","in":"query","schema":{"type":"string","enum":["lead","prospect","client","partner"]},"description":"Filtra por tipo. Un valor fuera del enum es 400."}],"responses":{"200":{"description":"Página de contactos","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"first_name":{"type":"string"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"phone_e164":{"type":"string","example":"+34600111222"},"phone_display":{"type":["string","null"],"example":"600 111 222"},"email":{"type":["string","null"]},"position":{"type":["string","null"]},"type":{"type":"string","enum":["lead","prospect","client","partner"]},"status":{"type":"string","enum":["new","contacted","interested","qualified","callback","no_answer","voicemail","not_interested","converted","disqualified","do_not_call","invalid"]},"tags":{"type":"array","items":{"type":"string"}},"source":{"type":"string","example":"api"},"external_id":{"type":["string","null"],"description":"Tu identificador de origen."},"opt_out":{"type":"boolean","description":"El contacto se ha dado de baja. **Se publica a propósito**: quien lee esta lista para llamar o escribir necesita saber a quién no puede."},"do_not_call":{"type":"boolean","description":"No llamar. Misma razón que `opt_out`."},"last_call_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Contactos"],"summary":"Crear un contacto","description":"Da de alta un contacto en el CRM del tenant. El teléfono se normaliza a E.164 en servidor (`600111222` → `+34600111222`). `source` se fuerza a `api`. Requiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"createContact","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateContactRequest"},"example":{"phone_e164":"+34600111222","first_name":"Ana","last_name":"García","company":"Acme SL","email":"ana@acme.es","type":"lead","tags":["webinar-julio"]}}}},"responses":{"201":{"description":"Contacto creado","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientWriteScope"},"404":{"$ref":"#/components/responses/WriteDisabled"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/api/v1/contacts/{contact_id}":{"get":{"tags":["Contactos"],"summary":"Consultar un contacto","description":"Devuelve un contacto concreto, con la MISMA proyección que el listado — leer uno y leerlo dentro de una página tienen que dar el mismo objeto.\n\n**Para qué existe:** `PATCH` escribe `opt_out` y `do_not_call` desde la primera entrega; para LEERLOS había que paginar el CRM entero hasta dar con la fila. Si marcas una exclusión y quieres confirmarla antes de llamar —que es cuando hay que confirmarla— ésta es la operación.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `contacts:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`. Si tu clave está en modo «acceso completo», pídele al distribuidor que le marque «Listar y buscar contactos».","operationId":"getContact","parameters":[{"name":"contact_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"El contacto","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"first_name":{"type":"string"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"phone_e164":{"type":"string","example":"+34600111222"},"phone_display":{"type":["string","null"],"example":"600 111 222"},"email":{"type":["string","null"]},"position":{"type":["string","null"]},"type":{"type":"string","enum":["lead","prospect","client","partner"]},"status":{"type":"string","enum":["new","contacted","interested","qualified","callback","no_answer","voicemail","not_interested","converted","disqualified","do_not_call","invalid"]},"tags":{"type":"array","items":{"type":"string"}},"source":{"type":"string","example":"api"},"external_id":{"type":["string","null"],"description":"Tu identificador de origen."},"opt_out":{"type":"boolean","description":"El contacto se ha dado de baja. **Se publica a propósito**: quien lee esta lista para llamar o escribir necesita saber a quién no puede."},"do_not_call":{"type":"boolean","description":"No llamar. Misma razón que `opt_out`."},"last_call_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Contactos"],"summary":"Actualizar un contacto","description":"Actualización parcial: solo cambian los campos presentes en el body. Un contacto de otro tenant devuelve 404 (nunca 403 — un 403 confirmaría que el id existe). Requiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"updateContact","parameters":[{"name":"contact_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateContactRequest"},"example":{"status":"qualified","tags":["webinar-julio","demo-pedida"]}}}},"responses":{"200":{"description":"Contacto actualizado","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientWriteScope"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/api/v1/contact-lists":{"get":{"tags":["Listas"],"summary":"Listar las listas de contactos","description":"Enumera las listas del tenant. **Paginada de serie**: una cuenta de producción tiene cientos, y una respuesta sin techo no distingue «no tienes listas» de «tienes demasiadas».\n\nSe sirve siempre: a diferencia del `POST` de esta misma ruta, no depende de `PUBLIC_API_WRITE_ENABLED`.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `contacts:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`. Si tu clave está en modo «acceso completo», pídele al distribuidor que le marque «Listar y buscar contactos».","operationId":"listContactLists","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de listas","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Leads webinar julio"},"description":{"type":["string","null"]},"source":{"type":"string","example":"api"},"contact_count":{"type":"integer","description":"Lo mantiene un disparador de la base de datos sobre `contact_list_members`; no es un campo editable."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Listas"],"summary":"Crear una lista de contactos","description":"Crea una lista vacía. Es una operación inerte: no marca, no gasta. Requiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"createContactList","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":200,"example":"Leads webinar julio"},"description":{"type":["string","null"]}}}}}},"responses":{"201":{"description":"Lista creada","content":{"application/json":{"schema":{"type":"object","properties":{"list":{"$ref":"#/components/schemas/ContactList"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientWriteScope"},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/api/v1/contact-lists/{list_id}":{"get":{"tags":["Listas"],"summary":"Consultar una lista","description":"Devuelve una lista concreta, con la MISMA proyección que devuelve el `PATCH` — leer y editar tienen que dar el mismo objeto del mismo recurso.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `contacts:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`. Si tu clave está en modo «acceso completo», pídele al distribuidor que le marque «Listar y buscar contactos».","operationId":"getContactList","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"La lista","content":{"application/json":{"schema":{"type":"object","properties":{"list":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Leads webinar julio"},"description":{"type":["string","null"]},"source":{"type":"string","example":"api"},"contact_count":{"type":"integer","description":"Lo mantiene un disparador de la base de datos sobre `contact_list_members`; no es un campo editable."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Listas"],"summary":"Renombrar o describir una lista","description":"Cambia solo los metadatos de la lista. **No toca su contenido**: los contactos que hay dentro no se ven afectados.\n\n`contact_count`, `source` y las fechas los mantiene el sistema y no son editables.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera.**","operationId":"updateContactList","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Los dos campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"`null` la limpia."}}},"example":{"name":"Leads webinar julio (revisada)"}}}},"responses":{"200":{"description":"Lista actualizada","content":{"application/json":{"schema":{"type":"object","properties":{"list":{"$ref":"#/components/schemas/ContactList"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/contact-lists/{list_id}/members":{"get":{"tags":["Listas"],"summary":"Listar los contactos de una lista","description":"Los contactos que hay DENTRO de una lista, paginados y ordenados por fecha de alta en la lista (el más reciente primero).\n\n`pagination.total` es el total de la lista entera, no el de esta página: es el número con el que decides cuándo dejar de paginar.\n\n**Una lista ajena responde 404, no una página vacía.** Un `200 { data: [] }` confirmaría que ese identificador existe.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Permiso obligatorio.** A diferencia de las lecturas anteriores de esta API, esta operación exige el scope `contacts:read` de forma explícita: una clave antigua sin permisos declarados **no** lo hereda y recibe `403 insufficient_scope`. Si tu clave está en modo «acceso completo», pídele al distribuidor que le marque «Listar y buscar contactos».","operationId":"listContactListMembers","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Página de miembros de la lista","content":{"application/json":{"schema":{"type":"object","required":["list_id","data","pagination"],"properties":{"list_id":{"type":"string","format":"uuid"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"first_name":{"type":"string"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"phone_e164":{"type":"string","example":"+34600111222"},"phone_display":{"type":["string","null"],"example":"600 111 222"},"email":{"type":["string","null"]},"position":{"type":["string","null"]},"type":{"type":"string","enum":["lead","prospect","client","partner"]},"status":{"type":"string","enum":["new","contacted","interested","qualified","callback","no_answer","voicemail","not_interested","converted","disqualified","do_not_call","invalid"]},"tags":{"type":"array","items":{"type":"string"}},"source":{"type":"string","example":"api"},"external_id":{"type":["string","null"],"description":"Tu identificador de origen."},"opt_out":{"type":"boolean","description":"El contacto se ha dado de baja. **Se publica a propósito**: quien lee esta lista para llamar o escribir necesita saber a quién no puede."},"do_not_call":{"type":"boolean","description":"No llamar. Misma razón que `opt_out`."},"last_call_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"post":{"tags":["Listas"],"summary":"Añadir contactos a una lista","description":"Añade contactos existentes a una lista (idempotente: repetir la llamada es seguro). Máximo 500 ids por petición. Los ids que no pertenezcan a tu tenant NO se escriben y se devuelven en `rejected`, de modo que un id ajeno nunca acaba injertado en tu lista. Requiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"addContactsToList","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contact_ids"],"properties":{"contact_ids":{"type":"array","minItems":1,"maxItems":500,"items":{"type":"string","format":"uuid"}}}},"example":{"contact_ids":["6f1c…","8a20…"]}}}},"responses":{"200":{"description":"Resultado del alta en la lista","content":{"application/json":{"schema":{"type":"object","properties":{"list_id":{"type":"string"},"added":{"type":"integer","description":"Contactos añadidos."},"rejected":{"type":"array","items":{"type":"string"},"description":"Ids que no existen en tu tenant. No se han escrito."}}},"example":{"list_id":"3b9e…","added":2,"rejected":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/InsufficientWriteScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimitedLegacy"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Listas"],"summary":"Quitar contactos de una lista","description":"Deshace la pertenencia. **No borra los contactos**: es la operación que un integrador necesita para mantener una audiencia sincronizada con su CRM sin destruir el CRM de AMAI.\n\nEl cuerpo va en el DELETE, simétrico con el POST de arriba.\n\n**Idempotente sin cabecera:** quitar una pertenencia que ya no existe es una operación nula y responde 200. `removed` cuenta las filas que este borrado quitó de verdad, para distinguir «ya no estaban» de «los he quitado yo».\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"removeContactsFromList","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contact_ids"],"properties":{"contact_ids":{"type":"array","minItems":1,"maxItems":500,"items":{"type":"string","format":"uuid"}}}},"example":{"contact_ids":["6f1c0000-0000-4000-8000-000000000001"]}}}},"responses":{"200":{"description":"Contactos retirados de la lista","content":{"application/json":{"schema":{"type":"object","properties":{"list_id":{"type":"string","format":"uuid"},"removed":{"type":"integer"}}},"example":{"list_id":"3b9e…","removed":1}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/subaccounts/{subaccount_id}":{"get":{"tags":["Marca blanca"],"summary":"Leer una cuenta del árbol","operationId":"getSubaccount","description":"Resuelve un identificador suelto en vez de paginar el árbol entero. Es lo que hace falta cuando tu CRM ya guarda el id y solo quiere refrescar el estado de un cliente.\n\n### El alcance no se pide, se deriva\n\nEl identificador tiene que pertenecer a tu árbol. Se comprueba por **dos** caminos independientes que tienen que coincidir: el prefijo de la ruta de jerarquía (el mismo que usa `GET /api/v1/subaccounts`) y la cadena viva de `parent_id` hacia arriba. Un árbol con un ciclo **deniega**, no cuelga.\n\n`self` distingue tu propia cuenta; `direct_child` distingue a las hijas de las nietas.\n\nScope `subaccounts:read`, **exigido siempre** — una clave antigua sin permisos declarados no lo hereda. Función de cuenta `create_subaccounts` (solo `agency` y `partner`).","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"responses":{"200":{"description":"La cuenta","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","status","balance_usd","direct_child","self"],"properties":{"id":{"type":"string","format":"uuid"},"company_name":{"type":["string","null"]},"contact_name":{"type":["string","null"]},"email":{"type":["string","null"]},"role":{"type":["string","null"],"example":"client"},"plan_slug":{"type":["string","null"],"example":"free"},"status":{"type":["string","null"],"example":"active"},"account_kind":{"type":["string","null"]},"account_profile":{"type":["string","null"]},"billing_mode":{"type":["string","null"]},"balance_usd":{"type":"number","example":12.4},"direct_child":{"type":"boolean"},"hierarchy_depth":{"type":["integer","null"]},"self":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/subaccounts/{subaccount_id}/branding":{"get":{"tags":["Marca blanca"],"summary":"Leer la marca de una cuenta del árbol","operationId":"getSubaccountBranding","description":"Cómo se ve el panel de esa cuenta: nombre comercial, logo, colores, dominio propio y si está verificado.\n\n### El alcance no se pide, se deriva\n\nEl identificador tiene que pertenecer a tu árbol. Se comprueba por **dos** caminos independientes que tienen que coincidir: el prefijo de la ruta de jerarquía (el mismo que usa `GET /api/v1/subaccounts`) y la cadena viva de `parent_id` hacia arriba. Un árbol con un ciclo **deniega**, no cuelga.\n\nUna cuenta que nunca ha guardado su marca devuelve los mismos valores por defecto que muestra el panel, no un `404`: la marca de una cuenta siempre existe, aunque sea la de fábrica.\n\n`domain_verification` viaja aunque no haya dominio guardado — es la instrucción del paso siguiente, y esconderla obligaría a dos peticiones para saber algo que se sabe desde el principio.\n\nLas verjas de empaquetado (`allow_signup`, `hide_plans`, `hide_addons`) **no están aquí**: viven en `…/packaging`. Deciden si alguien puede darse de alta o comprar, y mezclarlas con los colores es como se acaba cambiando una política comercial creyendo que se retoca un logo.\n\nScope `branding:read`, **exigido siempre**. Función de cuenta `manage_descendant_branding`, que concede el ROL (`partner`, `agency`) y nunca el plan.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"responses":{"200":{"description":"La marca de la cuenta","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["account_id","self"],"properties":{"account_id":{"type":"string","format":"uuid"},"self":{"type":"boolean","description":"`true` cuando el identificador pedido es el de la propia cuenta."},"company_name":{"type":["string","null"],"example":"Gravity Telecom"},"logo_url":{"type":["string","null"]},"favicon_url":{"type":["string","null"]},"primary_color":{"type":["string","null"],"example":"#00a86b"},"secondary_color":{"type":["string","null"],"example":"#059669"},"support_email":{"type":["string","null"]},"support_url":{"type":["string","null"]},"hide_powered_by":{"type":"boolean"},"custom_domain":{"type":["string","null"],"example":"panel.miempresa.com"},"custom_domain_verified":{"type":"boolean","description":"**No se puede escribir.** Lo pone `POST …/branding/verify-domain` tras leer el DNS, y cambiar `custom_domain` lo devuelve a `false` en la misma escritura."},"custom_domain_verified_at":{"type":["string","null"],"format":"date-time"},"api_domain":{"type":["string","null"],"description":"Derivado por la plataforma. Solo lectura."},"background":{"type":"object","description":"Solo lectura por API. El fondo se compone en el panel, con la vista previa delante.","properties":{"mode":{"type":["string","null"]},"image_url":{"type":["string","null"]},"repeat":{"type":["string","null"]},"size":{"type":["string","null"]},"position":{"type":["string","null"]}}},"domain_verification":{"type":["object","null"],"description":"El registro TXT que hay que publicar para verificar el dominio guardado.","properties":{"type":{"type":"string","enum":["TXT"]},"host":{"type":"string","example":"_amai-verify.panel.miempresa.com"},"value":{"type":"string","example":"amai-verify=8e5af4de-0000-0000-0000-000000000000"}}},"custom_domain_allowed":{"type":"boolean","description":"Si el plan de **esta** cuenta admite dominio propio (Partner+). Se publica para que sepas por qué te van a decir que no antes de intentarlo."},"updated_at":{"type":["string","null"],"format":"date-time"}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Marca blanca"],"summary":"Editar la marca de una cuenta del árbol","operationId":"updateSubaccountBranding","description":"Actualización **parcial**: solo se toca lo que mandas. Devuelve la marca completa después de guardar.\n\n### El alcance no se pide, se deriva\n\nEl identificador tiene que pertenecer a tu árbol. Se comprueba por **dos** caminos independientes que tienen que coincidir: el prefijo de la ruta de jerarquía (el mismo que usa `GET /api/v1/subaccounts`) y la cadena viva de `parent_id` hacia arriba. Un árbol con un ciclo **deniega**, no cuelga.\n\n### Lo que esta operación NO escribe\n\n- `custom_domain_verified` y `custom_domain_verified_at`. No hay ningún campo que los ponga, ni aquí ni en otra operación: verificar un dominio es una afirmación sobre el mundo exterior y no puede salir del JSON que manda quien quiere que sea verdad. Los pone `POST …/branding/verify-domain` tras leer el DNS. Mandarlos se **ignora en silencio**.\n- `api_domain`: lo deriva la plataforma.\n- `background_*`: se componen en el panel, con la vista previa delante.\n\n### Cambiar el dominio revoca la verificación\n\nEn la misma escritura y sin ventana. Si no, apuntar el dominio a otro sitio conservaría el sello del anterior, que es exactamente la vuelta que hay que dar para heredar una verificación que no se ha ganado.\n\n### Dominio propio: dos puertas\n\n1. **El plan de la cuenta DESTINO** — Partner o superior. Un partner no puede regalarle a su cliente `free` una función que ese cliente no paga: `403 domain_not_allowed`. El panel, en cambio, ignora el valor en silencio y devuelve 200; aquí no, porque un 200 que no guardó nada es peor que un error.\n2. **Que no lo tenga ya otra cuenta** — `409 domain_taken`. Dos filas con el mismo dominio rompen la resolución del inquilino por host y **las dos** cuentas pierden su marca a la vez, incluido su `allow_signup`.\n\nLas URL tienen que ser `https://`: el panel se sirve por HTTPS y el navegador bloquea un logo en claro. Guardarlo daría un 200 y una imagen invisible.\n\nScope `branding:write` · función de cuenta `manage_descendant_branding` · exige `PUBLIC_API_WRITE_ENABLED`.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales. `null` limpia el campo.","properties":{"company_name":{"type":["string","null"],"maxLength":120},"logo_url":{"type":["string","null"],"maxLength":2048,"example":"https://cdn.miempresa.com/logo.svg"},"favicon_url":{"type":["string","null"],"maxLength":2048},"primary_color":{"type":["string","null"],"example":"#00a86b"},"secondary_color":{"type":["string","null"],"example":"#059669"},"support_email":{"type":["string","null"],"maxLength":254},"support_url":{"type":["string","null"],"maxLength":2048},"hide_powered_by":{"type":"boolean"},"custom_domain":{"type":["string","null"],"maxLength":253,"example":"panel.miempresa.com","description":"Se normaliza (minúsculas, sin esquema, sin ruta). `null` lo quita."}}},"example":{"company_name":"Gravity Telecom","primary_color":"#00a86b","custom_domain":"panel.gravity.example"}}}},"responses":{"200":{"description":"La marca después de guardar","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["account_id","self"],"properties":{"account_id":{"type":"string","format":"uuid"},"self":{"type":"boolean","description":"`true` cuando el identificador pedido es el de la propia cuenta."},"company_name":{"type":["string","null"],"example":"Gravity Telecom"},"logo_url":{"type":["string","null"]},"favicon_url":{"type":["string","null"]},"primary_color":{"type":["string","null"],"example":"#00a86b"},"secondary_color":{"type":["string","null"],"example":"#059669"},"support_email":{"type":["string","null"]},"support_url":{"type":["string","null"]},"hide_powered_by":{"type":"boolean"},"custom_domain":{"type":["string","null"],"example":"panel.miempresa.com"},"custom_domain_verified":{"type":"boolean","description":"**No se puede escribir.** Lo pone `POST …/branding/verify-domain` tras leer el DNS, y cambiar `custom_domain` lo devuelve a `false` en la misma escritura."},"custom_domain_verified_at":{"type":["string","null"],"format":"date-time"},"api_domain":{"type":["string","null"],"description":"Derivado por la plataforma. Solo lectura."},"background":{"type":"object","description":"Solo lectura por API. El fondo se compone en el panel, con la vista previa delante.","properties":{"mode":{"type":["string","null"]},"image_url":{"type":["string","null"]},"repeat":{"type":["string","null"]},"size":{"type":["string","null"]},"position":{"type":["string","null"]}}},"domain_verification":{"type":["object","null"],"description":"El registro TXT que hay que publicar para verificar el dominio guardado.","properties":{"type":{"type":"string","enum":["TXT"]},"host":{"type":"string","example":"_amai-verify.panel.miempresa.com"},"value":{"type":"string","example":"amai-verify=8e5af4de-0000-0000-0000-000000000000"}}},"custom_domain_allowed":{"type":"boolean","description":"Si el plan de **esta** cuenta admite dominio propio (Partner+). Se publica para que sepas por qué te van a decir que no antes de intentarlo."},"updated_at":{"type":["string","null"],"format":"date-time"}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El interruptor `PUBLIC_API_WRITE_ENABLED` está apagado, **o** la cuenta no cuelga de la tuya. Los dos casos responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"Subcuenta no encontrada"}}}}},"409":{"description":"Ese dominio ya está asignado a otra cuenta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"domain_taken","message":"Ese dominio ya está asignado a otra cuenta."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/subaccounts/{subaccount_id}/packaging":{"get":{"tags":["Marca blanca"],"summary":"Leer las verjas de empaquetado de una cuenta del árbol","operationId":"getSubaccountPackaging","description":"Los tres booleanos que deciden qué puede **contratar** esa cuenta y si admite altas por su cuenta.\n\n### El alcance no se pide, se deriva\n\nEl identificador tiene que pertenecer a tu árbol. Se comprueba por **dos** caminos independientes que tienen que coincidir: el prefijo de la ruta de jerarquía (el mismo que usa `GET /api/v1/subaccounts`) y la cadena viva de `parent_id` hacia arriba. Un árbol con un ciclo **deniega**, no cuelga.\n\nScope `branding:read`, **exigido siempre**. Función de cuenta `manage_descendant_branding`.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"responses":{"200":{"description":"Las verjas","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["account_id","self","allow_signup","hide_plans","hide_addons"],"properties":{"account_id":{"type":"string","format":"uuid"},"self":{"type":"boolean"},"allow_signup":{"type":"boolean","description":"Si alguien puede darse de alta solo en el dominio de esta cuenta. **Se aplica en el backend**, no es una verja de interfaz: a `false`, las rutas de creación de cuenta rechazan el alta."},"hide_plans":{"type":"boolean","description":"A `true`, contratar un plan responde `403 commercial_gate_closed`."},"hide_addons":{"type":"boolean","description":"A `true`, contratar un complemento responde `403 commercial_gate_closed`. Darse de **baja** nunca se cierra: es una bandera comercial, no una cárcel."}}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}},"patch":{"tags":["Marca blanca"],"summary":"Editar las verjas de empaquetado de una cuenta del árbol","operationId":"updateSubaccountPackaging","description":"Actualización parcial de los tres booleanos.\n\n### El alcance no se pide, se deriva\n\nEl identificador tiene que pertenecer a tu árbol. Se comprueba por **dos** caminos independientes que tienen que coincidir: el prefijo de la ruta de jerarquía (el mismo que usa `GET /api/v1/subaccounts`) y la cadena viva de `parent_id` hacia arriba. Un árbol con un ciclo **deniega**, no cuelga.\n\n### Sobre uno mismo, no\n\nEl `GET` acepta tu propia cuenta; este `PATCH` responde `403 self_not_allowed`. `hide_plans` y `hide_addons` no las pone hoy el inquilino, las pone quien le sirve la plataforma: si una cuenta pudiera reescribir las suyas, cualquier acuerdo comercial fijado sobre ella se desharía con una llamada. Sobre tus **descendientes** sí, porque ahí el dueño comercial de la relación eres tú.\n\nDicho al revés: esta operación solo puede cambiar la oferta de otros, nunca ampliar la propia.\n\n### `allow_signup` no es un adorno\n\nA `false`, las rutas de creación de cuenta rechazan el alta en el dominio de esa cuenta. Es la misma verja que aplica el backend al panel, no una copia.\n\n### Dos tablas, sin transacción\n\n`allow_signup` vive en la marca y las otras dos en las preferencias del distribuidor. Un fallo en la segunda escritura deja la primera aplicada. La operación es **idempotente** —manda valores absolutos— así que repetir la misma petición converge al estado pedido.\n\nScope `branding:write` · función de cuenta `manage_descendant_branding` · exige `PUBLIC_API_WRITE_ENABLED`.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos opcionales; al menos uno.","properties":{"allow_signup":{"type":"boolean"},"hide_plans":{"type":"boolean"},"hide_addons":{"type":"boolean"}}},"example":{"allow_signup":false,"hide_plans":true}}}},"responses":{"200":{"description":"Las verjas después de guardar","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["account_id","self","allow_signup","hide_plans","hide_addons"],"properties":{"account_id":{"type":"string","format":"uuid"},"self":{"type":"boolean"},"allow_signup":{"type":"boolean","description":"Si alguien puede darse de alta solo en el dominio de esta cuenta. **Se aplica en el backend**, no es una verja de interfaz: a `false`, las rutas de creación de cuenta rechazan el alta."},"hide_plans":{"type":"boolean","description":"A `true`, contratar un plan responde `403 commercial_gate_closed`."},"hide_addons":{"type":"boolean","description":"A `true`, contratar un complemento responde `403 commercial_gate_closed`. Darse de **baja** nunca se cierra: es una bandera comercial, no una cárcel."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El interruptor `PUBLIC_API_WRITE_ENABLED` está apagado, **o** la cuenta no cuelga de la tuya. Los dos casos responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"Subcuenta no encontrada"}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/campaigns/{campaign_id}/enrollments":{"get":{"tags":["Campañas"],"summary":"Inscripciones de una campaña","description":"A quién metió la campaña, y dónde está cada uno.\n\nCada inscripción lleva **dos ejes que no significan lo mismo** y que hay que\nleer por separado:\n\n- `operational_state` — dónde está en el MARCADOR: pendiente, reintento\n  programado, intentos agotados, completada… Lo mueve la telefonía.\n- `commercial_stage` — dónde está en el EMBUDO: contactado, cualificado, cita\n  agendada, perdido… Lo mueve el juicio comercial, nunca un desenlace de\n  telefonía.\n\nUna llamada que no contesta mueve el primero y **no** el segundo. Confundirlos\nes el error clásico al integrar: da conversiones que no existen.\n\n`qualification_id` enlaza con `GET /api/v1/qualifications` y\n`opportunity_id` con la oportunidad del embudo (todavía sin lectura pública).","operationId":"listCampaignEnrollments","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Identificador de la campaña. De otro inquilino o inexistente responden el mismo 404."},{"name":"operational_state","in":"query","schema":{"type":"string","enum":["ready","scheduled","dialing","ringing","connected","in_conversation","retry_scheduled","no_answer","busy","voicemail","provider_failure","invalid_number","attempts_exhausted","completed","suppressed","excluded","cancelled"]},"description":"Filtra por estado del marcador. Un valor fuera de dominio responde 400."},{"name":"commercial_stage","in":"query","schema":{"type":"string","enum":["new","contacted","discovery","interest_detected","qualification_in_progress","qualified","appointment_proposed","appointment_booked","follow_up","proposal","negotiation","won","lost","disqualified"]},"description":"Filtra por etapa del embudo. Un valor fuera de dominio responde 400."},{"name":"contact_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"La inscripción de un contacto concreto en esta campaña."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Las inscripciones, de la más recientemente movida a la más antigua.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","campaign_id","contact_id","operational_state","commercial_stage"],"properties":{"id":{"type":"string","format":"uuid"},"campaign_id":{"type":"string","format":"uuid"},"contact_id":{"type":"string","format":"uuid"},"operational_state":{"type":"string","enum":["ready","scheduled","dialing","ringing","connected","in_conversation","retry_scheduled","no_answer","busy","voicemail","provider_failure","invalid_number","attempts_exhausted","completed","suppressed","excluded","cancelled"]},"commercial_stage":{"type":"string","enum":["new","contacted","discovery","interest_detected","qualification_in_progress","qualified","appointment_proposed","appointment_booked","follow_up","proposal","negotiation","won","lost","disqualified"]},"commercial_attempts_used":{"type":"integer","minimum":0},"technical_retries_used":{"type":"integer","minimum":0},"attempt_sequence":{"type":"integer","minimum":0},"next_attempt_at":{"type":["string","null"],"format":"date-time"},"next_attempt_type":{"type":["string","null"],"enum":["commercial","technical","callback","channel_followup","manual",null]},"stop_reason":{"type":["string","null"],"description":"Por qué se dejó de intentar. `null` mientras siga viva."},"opportunity_id":{"type":["string","null"],"format":"uuid"},"qualification_id":{"type":["string","null"],"format":"uuid","description":"Veredicto de cualificación asociado. Se lee en `GET /api/v1/qualifications`."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"3f1c9a52-0f4e-4d1a-9d33-6a1b2c4d5e6f","campaign_id":"9a7b6c5d-4e3f-4a2b-8c1d-0e9f8a7b6c5d","contact_id":"1b2c3d4e-5f60-4712-8394-a5b6c7d8e9f0","operational_state":"retry_scheduled","commercial_stage":"new","commercial_attempts_used":1,"technical_retries_used":0,"attempt_sequence":1,"next_attempt_at":"2026-08-06T09:30:00.000Z","next_attempt_type":"commercial","stop_reason":null,"opportunity_id":null,"qualification_id":"7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f","created_at":"2026-08-01T08:00:00.000Z","updated_at":"2026-08-05T11:12:00.000Z"}],"pagination":{"limit":50,"offset":0,"total":236,"has_more":true}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/campaigns/{campaign_id}/attempts":{"get":{"tags":["Campañas"],"summary":"Intentos de una campaña","description":"Qué pasó en cada llamada de la campaña.\n\n`call_id` es el identificador **público** de la llamada: el mismo que\n`GET /api/v1/calls` devuelve como `id`. Con él se piden la grabación, la\ntranscripción y el coste. Es `null` mientras el intento no tenga registro de\nllamada cerrado.\n\n**El coste no sale por aquí.** La fila lo guarda, pero el dinero de una llamada\nse sirve en un solo sitio —`GET /api/v1/calls`, con el desglose que separa\ntelefonía de IA— para que no haya dos contabilidades que se puedan\ndesincronizar.\n\n`technical_reason` sólo viene relleno cuando el desenlace fue\n`provider_failure`: dice si la avería fue nuestra o del destino.","operationId":"listCampaignAttempts","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Identificador de la campaña. De otro inquilino o inexistente responden el mismo 404."},{"name":"enrollment_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"Sólo los intentos de una inscripción."},{"name":"outcome","in":"query","schema":{"type":"string","enum":["no_answer","busy","voicemail","provider_failure","meeting_booked","do_not_call","answered","invalid_number","not_interested"]},"description":"Filtra por desenlace. Un valor fuera de dominio responde 400."},{"name":"attempt_type","in":"query","schema":{"type":"string","enum":["commercial","technical","callback","channel_followup","manual"]},"description":"Comercial, técnico, callback, seguimiento por canal o manual."},{"name":"channel","in":"query","schema":{"type":"string","enum":["voice","whatsapp","email"]},"description":"Por dónde salió el intento."},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Los intentos, del más reciente al más antiguo.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","enrollment_id","sequence_number","attempt_type","channel"],"properties":{"id":{"type":"string","format":"uuid"},"enrollment_id":{"type":"string","format":"uuid"},"contact_id":{"type":["string","null"],"format":"uuid"},"sequence_number":{"type":"integer","minimum":1},"attempt_type":{"type":"string","enum":["commercial","technical","callback","channel_followup","manual"]},"channel":{"type":"string","enum":["voice","whatsapp","email"]},"destination":{"type":["string","null"],"description":"Número o dirección marcada."},"outcome":{"type":["string","null"],"enum":["no_answer","busy","voicemail","provider_failure","meeting_booked","do_not_call","answered","invalid_number","not_interested",null]},"technical_reason":{"type":["string","null"],"description":"Sólo con `outcome = provider_failure`: de quién fue la avería."},"scheduled_for":{"type":["string","null"],"format":"date-time"},"started_at":{"type":["string","null"],"format":"date-time"},"answered_at":{"type":["string","null"],"format":"date-time","description":"`null` si no descolgaron. Es el campo que distingue un timbre de una conversación."},"ended_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"call_id":{"type":["string","null"],"description":"Identificador público de la llamada, el mismo que sirve `GET /api/v1/calls`."}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"8d7c6b5a-4930-4f2e-9a1b-2c3d4e5f6a7b","enrollment_id":"3f1c9a52-0f4e-4d1a-9d33-6a1b2c4d5e6f","contact_id":"1b2c3d4e-5f60-4712-8394-a5b6c7d8e9f0","sequence_number":1,"attempt_type":"commercial","channel":"voice","destination":"+34600111222","outcome":"answered","technical_reason":null,"scheduled_for":"2026-08-05T10:00:00.000Z","started_at":"2026-08-05T10:00:04.000Z","answered_at":"2026-08-05T10:00:19.000Z","ended_at":"2026-08-05T10:01:40.000Z","created_at":"2026-08-05T10:00:04.000Z","call_id":"call_01J9Z8QK2M4N6P8R0T2V4X6Z8A"}],"pagination":{"limit":50,"offset":0,"total":402,"has_more":true}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/qualifications":{"get":{"tags":["Llamadas"],"summary":"Veredictos de cualificación","description":"El veredicto del motor de cualificación sobre cada llamada.\n\nQué decidió, con cuánta confianza (`0`–`1`), qué nivel de interés detectó,\nqué puntuación de encaje dio (`0`–`100`) y qué etapa comercial propone.\n\n**Lo que esta operación NO devuelve**, y a propósito: el expediente interno del\nmotor —la evidencia con su fuerza, los deltas firmados de confianza y los\ncódigos de diagnóstico con los que una persona corrige una abstención—. Esa\nforma todavía se mueve con la rúbrica y publicarla la congelaría como contrato.\nVive en el panel, en la cola de revisión.\n\nPara llegar a la llamada, sigue `attempt_id` hasta\n`GET /api/v1/campaigns/{campaign_id}/attempts`, que sí publica el `call_id`\npúblico.","operationId":"listQualifications","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"decision","in":"query","schema":{"type":"string","enum":["auto_applied","human_review","rejected"]},"description":"`human_review` es la abstención del motor: quedó esperando a una persona."},{"name":"interest_level","in":"query","schema":{"type":"string","enum":["none","low","medium","high"]}},{"name":"contact_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"attempt_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Los veredictos, del más reciente al más antiguo.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","created_at","decision","fit_score","interest_level","confidence"],"properties":{"id":{"type":"string","format":"uuid"},"created_at":{"type":"string","format":"date-time"},"contact_id":{"type":["string","null"],"format":"uuid"},"attempt_id":{"type":["string","null"],"format":"uuid","description":"Intento del que salió el veredicto. Es la vía para llegar a la llamada."},"decision":{"type":"string","enum":["auto_applied","human_review","rejected"]},"fit_score":{"type":"integer","minimum":0,"maximum":100},"interest_level":{"type":"string","enum":["none","low","medium","high"]},"next_best_action":{"type":["string","null"]},"confidence":{"type":["number","null"],"minimum":0,"maximum":1,"description":"Confianza del motor. Número, no cadena: se normaliza antes de servirlo."},"proposed_stage":{"type":["string","null"],"description":"Etapa comercial que el motor propone. Proponer no es mover: la etapa vive en la inscripción."},"rejection_reason":{"type":["string","null"]},"profile_version":{"type":["string","null"]},"rubric_version":{"type":["string","null"]},"engine_version":{"type":["string","null"]}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"5e4d3c2b-1a09-4876-9f5e-4d3c2b1a0987","created_at":"2026-08-05T10:02:11.000Z","contact_id":"1b2c3d4e-5f60-4712-8394-a5b6c7d8e9f0","attempt_id":"8d7c6b5a-4930-4f2e-9a1b-2c3d4e5f6a7b","decision":"human_review","fit_score":62,"interest_level":"medium","next_best_action":"human_review","confidence":0.13,"proposed_stage":"interest_detected","rejection_reason":null,"profile_version":"v1","rubric_version":"v1","engine_version":"v1"}],"pagination":{"limit":50,"offset":0,"total":117,"has_more":true}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/bookings":{"get":{"tags":["Calendario"],"summary":"Citas agendadas","description":"Las citas del inquilino: las que agendó la IA durante una llamada y las que\nllegaron por el calendario conectado (Cal.com o Google Calendar).\n\nEl rango de fechas filtra por `slot_start` —**cuándo es** la cita, no cuándo\nse anotó— y el orden por defecto es ascendente, porque la agenda que importa\nestá por delante. Por eso la ventana por defecto va de hace 30 días a dentro de\n335, y no 30 días hacia atrás como el resto de las lecturas.\n\n`contact_id` viene relleno sólo cuando la cita se pudo atar a un contacto del\nCRM; hoy, en producción, casi ninguna lo está. No se publica un identificador de\nllamada porque el enlace cita→llamada aún no lo escribe ninguna de las citas que\nexisten, y un campo que siempre es nulo promete algo que el producto no da.\n\nEl correo y el teléfono del asistente **no** se publican: cuando la cita lleva\n`contact_id`, la ficha completa se pide en `GET /api/v1/contacts/{contact_id}`.","operationId":"listBookings","parameters":[{"name":"start_date","in":"query","schema":{"type":"string","example":"2026-07-01"},"description":"Inicio del rango, ISO-8601 (`2026-07-01` o `2026-07-01T00:00:00Z`). **Inclusivo.** Por defecto, hoy menos 30 días."},{"name":"end_date","in":"query","schema":{"type":"string","example":"2026-07-31"},"description":"Fin del rango, ISO-8601. **Inclusivo**: `end_date=2026-07-31` incluye el día 31 entero. Por defecto, ahora. El rango no puede superar 366 días."},{"name":"status","in":"query","schema":{"type":"string","enum":["confirmed","pending","cancelled","rescheduled","unknown"]}},{"name":"contact_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":500},"description":"Tamaño de página. Máximo 500."},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0},"description":"Desplazamiento para paginar."}],"responses":{"200":{"description":"Las citas, de la más próxima a la más lejana.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","slot_start","slot_end"],"properties":{"id":{"type":"string","format":"uuid"},"contact_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","enum":["confirmed","pending","cancelled","rescheduled","unknown"]},"title":{"type":["string","null"]},"slot_start":{"type":"string","format":"date-time"},"slot_end":{"type":"string","format":"date-time"},"timezone":{"type":["string","null"],"example":"Europe/Madrid"},"meeting_url":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de elementos que cumplen el filtro, no los de esta página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle."}}}}},"example":{"data":[{"id":"c1d2e3f4-a5b6-4708-9c1d-2e3f4a5b6c7d","contact_id":null,"status":"confirmed","title":"IMPULSA TU NEGOCIO CON AMAI","slot_start":"2026-08-11T14:00:00.000Z","slot_end":"2026-08-11T14:30:00.000Z","timezone":"Europe/Madrid","meeting_url":"https://meet.google.com/abc-defg-hij","created_at":"2026-08-04T18:20:00.000Z","updated_at":"2026-08-04T18:20:00.000Z"}],"pagination":{"limit":50,"offset":0,"total":3,"has_more":false}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/contacts/import":{"post":{"tags":["Contactos"],"summary":"Importar contactos en lote","description":"Vuelca hasta **500 contactos por petición**. Es la operación pensada para migrar un CRM entero sin hacer una llamada por contacto.\n\n## Idempotencia — dos garantías, y cuál es la fuerte\n\n**1 · Clave natural (siempre activa, no se puede desactivar).** Un contacto es único por `(tenant, teléfono)`. Reenviar el mismo lote **no puede** crear un segundo contacto: los repetidos se cuentan en `duplicates` y no se escriben. Esta garantía no depende de que hagas nada y la respalda un índice único en la base de datos, así que se mantiene incluso con dos peticiones idénticas en paralelo.\n\n**2 · Cabecera `Idempotency-Key` (opcional).** Lo que añade es la RESPUESTA: sin ella, el reintento devuelve `created: 0, duplicates: 500` — cifras correctas, pero un informe distinto al del primer intento, y un cliente que concilie por esa cifra creerá que su importación falló. Con la cabecera, el reintento devuelve **el informe original** y `idempotent_replay: true`. El ámbito es tu tenant.\n\n⚠ Lo que la cabecera **no** promete: dos peticiones con la misma clave lanzadas *a la vez* (no una tras otra) pueden generar dos informes. El efecto es un informe duplicado, **nunca un contacto duplicado**: la garantía 1 sigue en pie.\n\n## Consentimiento (RGPD)\n\nImportar **nunca** implica consentimiento. Esta operación no acepta campos de consentimiento y no registra ninguno. Si tu cuenta tiene la comprobación de consentimiento activada, los contactos importados por API quedan **excluidos de las campañas** hasta que se registre su consentimiento.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"importContacts","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":200},"description":"Identificador del lote, elegido por ti. Si repites la petición con la misma clave, se devuelve el informe original en vez de reprocesar."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contacts"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"contacts":{"type":"array","minItems":1,"maxItems":500,"description":"Máximo 500 por petición. Más devuelve 400 `batch_too_large`.","items":{"type":"object","required":["phone_e164","first_name"],"properties":{"phone_e164":{"type":"string","description":"Cualquier forma marcable; se normaliza a E.164.","example":"+34600111222"},"first_name":{"type":"string","maxLength":200},"last_name":{"type":["string","null"],"maxLength":500},"company":{"type":["string","null"],"maxLength":500},"email":{"type":["string","null"],"maxLength":500},"position":{"type":["string","null"],"maxLength":500},"notes":{"type":["string","null"],"maxLength":500},"external_id":{"type":["string","null"],"maxLength":500,"description":"Tu identificador de origen, para conciliar."},"tags":{"type":"array","maxItems":50,"items":{"type":"string","maxLength":100}},"type":{"$ref":"#/components/schemas/ContactType"},"status":{"$ref":"#/components/schemas/ContactStatus"},"metadata":{"type":"object","additionalProperties":true}}}},"list_id":{"type":"string","format":"uuid","description":"Lista EXISTENTE de tu tenant a la que añadirlos. Una lista ajena devuelve 404 **y no se importa ningún contacto**."},"list_name":{"type":"string","maxLength":200,"description":"Crea una lista nueva con este nombre. Se ignora si mandas `list_id`."}}},"example":{"list_name":"Migración CRM julio","contacts":[{"phone_e164":"+34600111222","first_name":"Ana","company":"Acme SL"},{"phone_e164":"+34600333444","first_name":"Luis","email":"luis@acme.es"}]}}}},"responses":{"201":{"description":"Lote procesado. Devuelve 201 también cuando `created` es 0 — el lote se procesó, simplemente ya existían todos.","content":{"application/json":{"schema":{"type":"object","properties":{"import_id":{"type":["string","null"],"format":"uuid"},"list_id":{"type":["string","null"],"format":"uuid"},"total":{"type":"integer","description":"Filas recibidas."},"created":{"type":"integer","description":"Contactos nuevos."},"duplicates":{"type":"integer","description":"Ya existían con ese teléfono. No se han tocado."},"errors":{"type":"integer"},"error_details":{"type":"array","maxItems":100,"description":"Hasta 100 detalles; `errors` sigue siendo el total exacto.","items":{"type":"object","properties":{"row":{"type":"integer"},"phone":{"type":"string"},"error":{"type":"string"}}}},"idempotent_replay":{"type":"boolean","description":"Presente y `true` SOLO cuando esta respuesta es la repetición de un lote anterior con la misma `Idempotency-Key`. Nada se ha reprocesado."}}},"example":{"import_id":"1c4e0000-0000-4000-8000-000000000009","list_id":"3b9e0000-0000-4000-8000-000000000002","total":2,"created":2,"duplicates":0,"errors":0,"error_details":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"La escritura pública está desactivada (`PUBLIC_API_WRITE_ENABLED`), o `list_id` no existe en tu tenant. En el segundo caso **no se ha importado ningún contacto**.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Lista no encontrada"}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/kb/collections/{collection_id}":{"patch":{"tags":["Base de conocimiento"],"summary":"Renombrar o describir una colección","description":"Cambia **solo los metadatos** de una colección: nombre, descripción, icono y color. No toca los documentos, no reindexa nada y no llama a ningún proveedor.\n\n## Qué NO ofrece esta API, y por qué\n\n**Subir documentos** no está disponible por API key: el indexador trocea el fichero y llama a la API de *embeddings* por cada trozo (y a transcripción si es audio), es decir **gasta dinero real**. Un PDF grande subido por un bucle mal escrito es una factura.\n\n**Crear colecciones** tampoco: aprovisiona infraestructura de almacenamiento vectorial, y hoy ese camino puede dejar una colección a medias si el aprovisionamiento falla.\n\nLas dos operaciones se hacen desde el panel.\n\n## Campos no editables\n\nLas coordenadas del almacén vectorial, el modelo de *embeddings*, el estado y los contadores de documentos y fragmentos son de solo lectura, y las coordenadas ni siquiera se publican. No es prudencia genérica: son el puntero al almacén, y reescribirlo apuntaría tu colección al contenido de otra cuenta.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera.**","operationId":"updateKbCollection","parameters":[{"name":"collection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"icon":{"type":["string","null"],"maxLength":50},"color":{"type":["string","null"],"pattern":"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","example":"#00a86b"}}},"example":{"name":"Manual de producto 2026","color":"#00a86b"}}}},"responses":{"200":{"description":"Colección actualizada","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["empty","indexing","ready","error"]},"chunk_count":{"type":"integer"},"doc_count":{"type":"integer"},"icon":{"type":["string","null"]},"color":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/sms/send":{"post":{"tags":["Utilidades"],"summary":"Enviar un SMS","description":"Envía un SMS con el remitente configurado en la cuenta.\n\n**Solo a móviles.** La plataforma clasifica el número con la numeración mundial y se niega a enviar sin prueba explícita de que es un móvil: un fijo devuelve `fixed_line` y un rango no clasificable devuelve `not_mobile`. Eso incluye **todo Estados Unidos y Canadá**, donde la portabilidad destruyó la distinción. No se puede forzar.\n\n**Coste.** Se cobra al tarifario de la cuenta, con su recargo, exactamente igual que un envío hecho desde el panel: es el mismo servicio por debajo. El importe queda en la fila del histórico (`cost_usd` de `GET /api/v1/sms/history`); esta respuesta no lo lleva.\n\n**Sin tarifa no se envía.** Si el país del destinatario no tiene precio en el tarifario de la cuenta, la operación devuelve `pricing_not_configured` y **no llama al operador**: un SMS cuyo coste no se puede calcular desaparecería de la factura sin dejar rastro. El intento sí queda en el histórico con `status: \"failed\"`. Ojo: que un país esté en `allowed_countries` no implica que tenga tarifa — son dos listas distintas.\n\n**Rastro.** El envío queda marcado como hecho por API y con la clave que lo hizo, para que después se pueda saber qué consumió cada integración.\n\nRequiere la capacidad `send_sms` en la cuenta y el canal de SMS configurado y activo.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"sendSms","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipient","body"],"properties":{"recipient":{"type":"string","description":"Destinatario en E.164. Tiene que ser un MÓVIL.","example":"+34600111222"},"body":{"type":"string","maxLength":1530,"description":"Texto del mensaje. Máximo 1530 caracteres (≈10 segmentos de 153).","example":"Aviso: quedan ~200 hojas en la impresora."},"contact_id":{"type":["string","null"],"format":"uuid","description":"Contacto al que asociar el envío, si lo hay."},"call_id":{"type":["string","null"],"format":"uuid","description":"Llamada a la que asociar el envío, si lo hay."}}},"example":{"recipient":"+34600111222","body":"Aviso: quedan ~200 hojas en la impresora."}}}},"responses":{"200":{"description":"El proveedor aceptó el envío.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message_id":{"type":"string","format":"uuid","description":"La fila del histórico. Con ella se consulta el estado y el coste."},"provider_message_id":{"type":["string","null"],"description":"El identificador que devolvió el operador."}}},"example":{"success":true,"message_id":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","provider_message_id":"didww-8f21a0"}}}},"400":{"description":"`invalid_json`, `channel_unavailable` (la cuenta no tiene canal de SMS), `missing_field`, `body_too_long`, `fixed_line`, `not_mobile`, `country_not_supported`, `invalid_number` o `pricing_not_configured` (el país del destino no tiene tarifa en la cuenta; no se envió nada).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_mobile","message":"No se ha podido verificar que el destinatario sea un móvil."}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`insufficient_scope` (la clave no lleva sms:send), `capability_required` (el plan no incluye send_sms) o `recipient_suppressed` (el destino está en la lista de exclusión / DNC del tenant; el payload es válido, es una regla de contacto la que niega, y reintentar el mismo número no lo arregla).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"recipient_suppressed","message":"El destinatario pidió no ser contactado (lista de exclusión / DNC). No se ha enviado nada."}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"502":{"description":"El operador rechazó el envío. Trae `message_id`: la fila de auditoría queda escrita igualmente, con el motivo del operador.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"provider_error","message":"El proveedor de SMS ha rechazado el envío."},"message_id":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9"}}}},"503":{"description":"`recipient_suppression_unverifiable`: no se pudo comprobar la lista de exclusión (lectura fail-closed caída). Es transitorio; reintenta en unos segundos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"recipient_suppression_unverifiable","message":"No se pudo comprobar la lista de exclusión. Inténtalo de nuevo en unos segundos."}}}}}}}},"/api/v1/sms/check":{"post":{"tags":["Utilidades"],"summary":"Verificar destinatarios de SMS (sin enviar)","description":"Dice, para cada número, si se le podría mandar un SMS — **antes** de gastarlo. No envía nada, no cobra nada y no escribe nada.\n\n**Para qué sirve.** Un SMS a un fijo **se cobra y no llega a nadie**: no hay devolución y no hay aviso. Con 5.000 contactos en un CSV, esto separa los buenos de los inservibles en 10 peticiones en vez de 5.000 envíos.\n\n**Los motivos son los mismos códigos que devolvería `POST /api/v1/sms/send`** para ese mismo número. Puedes tratar el veredicto y el error del envío con la misma rama de código.\n\n**Las dos trampas de España** — y cuestan dinero las dos:\n\n| Prefijo | Qué es | ¿SMS? |\n|---|---|---|\n| `6XXXXXXXX` | Móvil | Sí |\n| `71`–`74`, `78` | Móvil | Sí |\n| `70XXXXXXX` | Numeración **personal** (reencamina) | **No** — parece móvil, no lo es |\n| `8XXXXXXXX` | **Fijo** (`800` gratuito) | **No** — parece móvil, es fijo |\n| `9XXXXXXXX` | Fijo (`900` gratuito, `901`/`902` coste compartido) | No |\n| `806`, `807` | Tarificación adicional | No |\n\n**Formatos sucios.** Espacios, guiones, paréntesis, `0034` y el prefijo internacional explícito se digieren solos. Un número **nacional pelado** (`612345678`) necesita `default_country`; si tu cuenta tiene un único país habilitado se usa ése automáticamente, y la respuesta te dice cuál se aplicó. Un `+` explícito nunca se reescribe.\n\n**Comprueba la lista de exclusión (DNC).** Quien pidió no ser contactado sale con motivo propio `suppressed`, separado de los demás a propósito: «este número no existe» se arregla corrigiendo el dato, y «esta persona pidió que no le escribieran» **no se arregla** — se quita de la campaña.\n\n**Aun así, `sendable: true` no garantiza la entrega.** Significa: número válido, es móvil, su país está habilitado, tiene tarifa y hoy no está suprimido. La supresión puede cambiar entre tu consulta y tu envío, y el envío la vuelve a comprobar siempre. Este veredicto es una foto, no un permiso: no lo caches como si lo fuera.\n\nMisma puerta que el envío: scope `sms:send` y capacidad `send_sms`. Ni más abierto ni más cerrado que la operación que autoriza.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"checkSmsRecipients","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipients"],"properties":{"recipients":{"type":"array","minItems":1,"maxItems":500,"items":{"type":"string"},"description":"Entre 1 y 500 números. Se admiten formatos sucios. Trocear el lote no cambia ningún veredicto: la respuesta es determinista.","example":["+34600111222","700 000 000","912345678","612345678"]},"default_country":{"type":"string","minLength":2,"maxLength":2,"description":"ISO-3166 alpha-2. Solo se aplica a los números SIN prefijo internacional; nunca reescribe un `+` explícito. Si se omite y la cuenta tiene exactamente un país habilitado, se usa ése.","example":"ES"}}},"example":{"recipients":["+34600111222","700 000 000","912345678","612345678"],"default_country":"ES"}}}},"responses":{"200":{"description":"Veredicto por número. Es `200` **aunque no sea enviable ninguno**: un lote con fallos es el caso normal de esta operación, no una petición mal formada.","content":{"application/json":{"schema":{"type":"object","properties":{"checked":{"type":"integer","description":"Números evaluados."},"sendable":{"type":"integer","description":"Cuántos son enviables."},"default_country_applied":{"type":["string","null"],"description":"El país que se aplicó a los números sin prefijo. Se publica porque, cuando se infiere de la cuenta, tú no lo mandaste y no tendrías forma de saberlo."},"results":{"type":"array","items":{"type":"object","properties":{"input":{"type":"string","description":"El número tal y como lo mandaste, para casar la fila."},"sendable":{"type":"boolean"},"e164":{"type":["string","null"],"description":"Normalizado. Guárdalo así."},"country":{"type":["string","null"],"description":"ISO-3166 alpha-2."},"line_type":{"type":"string","description":"Clasificación cruda: `mobile`, `fixed_line`, `personal_number`, `toll_free`, `premium_rate`, `shared_cost`, `voip`, `fixed_line_or_mobile`, `unknown`. Dice POR QUÉ, donde `reason` dice qué haría el envío."},"reason":{"type":["string","null"],"enum":["invalid_number","fixed_line","not_mobile","country_not_supported","suppressed","pricing_not_configured",null],"description":"`null` si es enviable. Si no, el MISMO código que devolvería `POST /api/v1/sms/send`."},"message":{"type":["string","null"],"description":"Qué hacer: arreglar el dato o descartar el contacto."}}}}}},"example":{"checked":4,"sendable":2,"default_country_applied":"ES","results":[{"input":"+34600111222","sendable":true,"e164":"+34600111222","country":"ES","line_type":"mobile","reason":null,"message":null},{"input":"700 000 000","sendable":false,"e164":"+34700000000","country":"ES","line_type":"personal_number","reason":"not_mobile","message":"No se puede demostrar que sea un móvil (numeración personal, gratuita, de tarificación adicional, VoIP, o un país donde la portabilidad impide clasificarlo). No se envía sin esa prueba."},{"input":"912345678","sendable":false,"e164":"+34912345678","country":"ES","line_type":"fixed_line","reason":"fixed_line","message":"Es un número fijo. Un SMS a un fijo se cobra y no lo recibe nadie: descarta el contacto o pide un móvil."},{"input":"612345678","sendable":true,"e164":"+34612345678","country":"ES","line_type":"mobile","reason":null,"message":null}]}}}},"400":{"description":"`invalid_json`, `missing_field` (falta `recipients`, viene vacío o trae algo que no es texto), `too_many_recipients` (más de 500), `invalid_default_country` o `channel_unavailable` (la cuenta no tiene canal de SMS activo).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"too_many_recipients","message":"Máximo 500 números por petición (recibidos 1200). Parte el lote: la respuesta es determinista, así que trocearlo no cambia ningún veredicto."}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`insufficient_scope` (la clave no lleva sms:send) o `capability_required` (el plan no incluye send_sms). Es el mismo par que el envío: el verificador no está detrás de una puerta más abierta que la operación que autoriza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el scope sms:send."}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"503":{"description":"`recipient_suppression_unverifiable`: no se pudo comprobar la lista de exclusión (lectura fail-closed caída). Falla el **lote entero**, no números sueltos: una respuesta a medias donde unos dicen «no se sabe» se lee como «no suprimido», que es justo lo que esta comprobación evita. Es transitorio; reintenta en unos segundos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"recipient_suppression_unverifiable","message":"No se pudo comprobar la lista de exclusión. Inténtalo de nuevo en unos segundos."}}}}}}}},"/api/v1/whatsapp/check":{"post":{"tags":["WhatsApp"],"summary":"¿A quién se le puede escribir, y cómo?","description":"Verifica hasta 500 números **sin enviar nada y sin coste**: si son interpretables, si su tipo de línea puede recibir WhatsApp, si pidieron no ser contactados, y —lo más útil— si hay que mandarles **plantilla** o se admite **texto libre**.\n\n**NO dice si el número tiene WhatsApp.** La Cloud API de Meta no expone forma de saberlo sin enviar un mensaje, así que `whatsapp_registered` es SIEMPRE `\"unknown\"`. Está en el contrato a propósito, para que nadie lo suponga.\n\nDevuelve `200` aunque ninguno sea contactable: un lote donde la mitad falla es el caso normal. Exige `whatsapp:send`, el mismo scope que el enviador, porque un verificador más abierto que el envío sería una fuga.","operationId":"checkWhatsAppRecipients","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipients"],"properties":{"recipients":{"type":"array","minItems":1,"maxItems":500,"items":{"type":"string"},"description":"Números en E.164, o locales si mandas `default_country`."},"default_country":{"type":"string","description":"ISO-3166 alpha-2. Necesario si los números vienen sin prefijo internacional.","example":"ES"}}},"examples":{"lote":{"summary":"Un lote mezclado","value":{"recipients":["+34612345678","+34912345678","612345678"],"default_country":"ES"}}}}}},"responses":{"200":{"description":"Un veredicto por número, en el mismo orden que se pidieron.","content":{"application/json":{"schema":{"type":"object","properties":{"checked":{"type":"integer","example":3},"contactable":{"type":"integer","example":1},"results":{"type":"array","items":{"type":"object","properties":{"input":{"type":"string","description":"El número tal y como llegó."},"contactable":{"type":"boolean","description":"Se le puede escribir. NO significa «tiene WhatsApp»: eso no se sabe."},"e164":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"line_type":{"type":"string","description":"Clasificación cruda: `mobile`, `fixed_line`, `personal_number`, `fixed_line_or_mobile`, `voip`…"},"whatsapp_registered":{"type":"string","enum":["unknown"],"description":"SIEMPRE `unknown`. Meta no permite averiguarlo sin enviar."},"send_mode":{"type":"string","nullable":true,"enum":["free_form","template_required"],"description":"`free_form` si su ventana de 24 h está abierta; `template_required` si no. `null` cuando no es contactable."},"window_expires_at":{"type":"string","format":"date-time","nullable":true},"reason":{"type":"string","nullable":true,"enum":["invalid_number","fixed_line","not_mobile","suppressed","no_connection"]},"message":{"type":"string","nullable":true}}}}}},"examples":{"mezcla":{"summary":"Uno bueno, un fijo, uno normalizado","value":{"checked":3,"contactable":2,"results":[{"input":"+34612345678","contactable":true,"e164":"+34612345678","country":"ES","line_type":"mobile","whatsapp_registered":"unknown","send_mode":"template_required","window_expires_at":null,"reason":null,"message":null},{"input":"+34912345678","contactable":false,"e164":"+34912345678","country":"ES","line_type":"fixed_line","whatsapp_registered":"unknown","send_mode":null,"window_expires_at":null,"reason":"fixed_line","message":"Es un número fijo. WhatsApp vive en numeración móvil: el mensaje no llega y la plantilla se cobra igual. Pide un móvil."},{"input":"612345678","contactable":true,"e164":"+34612345678","country":"ES","line_type":"mobile","whatsapp_registered":"unknown","send_mode":"free_form","window_expires_at":"2026-08-11T09:12:00.000Z","reason":null,"message":null}]}}}}}},"400":{"description":"`invalid_json`, `missing_field` (falta `recipients`, viene vacío o trae algo que no es texto), `too_many_recipients` (más de 500) o `invalid_default_country`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"too_many_recipients","message":"Máximo 500 números por petición (recibidos 600). Parte el lote: la respuesta es determinista, así que trocearlo no cambia ningún veredicto."}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`insufficient_scope` (la clave no lleva whatsapp:send) o `capability_required` (el plan no incluye manage_whatsapp). Es el mismo par que el envío: el verificador no está detrás de una puerta más abierta que la operación que autoriza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el scope whatsapp:send."}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"503":{"description":"`connection_lookup_failed` (no se pudo comprobar la conexión de WhatsApp del tenant) o `recipient_suppression_unverifiable` (no se pudo comprobar la lista de exclusión). Los dos son fail-closed: ante la duda, no se emite veredicto. Transitorio; reintenta en unos segundos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"recipient_suppression_unverifiable","message":"No se pudo comprobar la lista de exclusión. Inténtalo de nuevo en unos segundos."}}}}}}}},"/api/v1/calls/{call_id}/labels":{"post":{"tags":["Llamadas"],"summary":"Etiquetar una llamada","description":"Clasifica una llamada: «Interesado», «Devolver llamada», o cualquier etiqueta propia de tu cuenta. Es la anotación que un CRM externo escribe automáticamente al clasificar una conversación.\n\nEtiquetas predefinidas: `002` Contactado · `003` No disponible · `004` Interesado · `005` No interesado · `006` Devolver llamada · `007` Venta cerrada · `008` Número erróneo · `009` Buzón de voz · `010` Seguimiento · `011` Incidencia. Las propias empiezan por `c_` y se crean en el panel.\n\n## ⛔ Las etiquetas de COLA no se admiten\n\nLas colas de departamento se guardan en la misma tabla que las etiquetas, pero no son una clasificación: son un **enrutado**. Asignar una en el panel crea un recado y dispara un aviso, es decir, escribe fuera de esta base de datos y origina tráfico. Por API se rechazan con 400 `queue_label_not_allowed` en vez de aceptarse y disparar un efecto que no pediste.\n\nLa comprobación es **por cuenta**: una misma clave puede ser una etiqueta normal en tu tenant y una cola en otro.\n\n**Idempotente sin cabecera:** la operación se resuelve sobre una clave natural única, así que reenviarla tras un timeout de red deja exactamente el mismo estado y no puede duplicar nada. Volver a poner una etiqueta que habías quitado la restaura.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"addCallLabel","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"},"description":"Id público de la llamada (`call_<ULID>`) o el UUID heredado del proveedor. Una llamada de otro tenant devuelve 404."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label_key"],"properties":{"label_key":{"type":"string","maxLength":100,"example":"004","description":"Etiqueta predefinida (`002`..`011`) o propia (`c_…`)."}}},"example":{"label_key":"004"}}}},"responses":{"200":{"description":"Etiqueta aplicada","content":{"application/json":{"schema":{"type":"object","properties":{"call_id":{"type":"string"},"label_key":{"type":"string"},"labeled":{"type":"boolean"}}},"example":{"call_id":"call_01J8…","label_key":"004","labeled":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/calls/{call_id}/labels/{label_key}":{"delete":{"tags":["Llamadas"],"summary":"Quitar una etiqueta de una llamada","description":"Retira la etiqueta.\n\n⚠ **Quitar no borra el registro: lo marca como retirado.** Para algunas cuentas, el listado de llamadas recalcula ciertas etiquetas en cada carga y las vuelve a escribir; con un borrado físico la etiqueta reaparecía sola y deshacía lo que acababas de hacer. La marca es lo que hace que la retirada persista. Volver a aplicarla con `POST` la restaura.\n\n**Idempotente sin cabecera:** quitar una etiqueta que ya estaba quitada, o que nunca estuvo, deja el mismo estado y responde 200. `removed` dice si este borrado cambió algo.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"removeCallLabel","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"},"description":"Id público de la llamada (`call_<ULID>`) o el UUID heredado del proveedor. Una llamada de otro tenant devuelve 404."},{"name":"label_key","in":"path","required":true,"schema":{"type":"string"},"description":"La etiqueta a retirar."}],"responses":{"200":{"description":"Etiqueta retirada (o no estaba)","content":{"application/json":{"schema":{"type":"object","properties":{"call_id":{"type":"string"},"label_key":{"type":"string"},"removed":{"type":"boolean","description":"`false` cuando la llamada no tenía esa etiqueta activa."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/calendar/schedule":{"put":{"tags":["Calendario"],"summary":"Fijar el horario semanal","description":"Define qué días se trabaja, de qué hora a qué hora y con qué descanso. Es lo que responde `GET /api/v1/calendar/check` cuando un agente pregunta si estáis abiertos.\n\n## ⚠ `day_of_week`: 0 es LUNES\n\n`0 = lunes` … `6 = domingo`. El `Date.getDay()` de JavaScript usa **0 = domingo**, así que pasarlo directamente escribe el horario **corrido un día**. El servidor no puede detectarlo —los dos valores están en rango y son legales—, y el fallo aparece cuando un agente diga que estáis cerrados un martes. Conviértelo tú.\n\n## Reemplazo PARCIAL\n\nSolo cambian los días que envías: mandar tres deja los otros cuatro como estaban. Un PUT que borrase los ausentes convertiría «corrige el viernes» en «cierra el resto de la semana».\n\n## Reglas que se validan antes de escribir\n\n- Día laborable ⇒ `opens_at` y `closes_at` presentes, y apertura antes que cierre.\n- El descanso es todo o nada: los dos extremos o ninguno.\n- El descanso cae dentro del horario y empieza antes de terminar.\n\nUn día marcado como no laborable se guarda **sin horas**, aunque las envíes.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera:** la operación se resuelve sobre una clave natural única, así que reenviarla tras un timeout de red deja exactamente el mismo estado y no puede duplicar nada.","operationId":"setCalendarSchedule","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["days"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"days":{"type":"array","minItems":1,"maxItems":7,"items":{"type":"object","required":["day_of_week","is_working_day"],"properties":{"day_of_week":{"type":"integer","minimum":0,"maximum":6,"description":"**0 = lunes**, 6 = domingo. No es `Date.getDay()`."},"is_working_day":{"type":"boolean"},"opens_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"closes_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"break_starts_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"break_ends_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."}}}}}},"example":{"days":[{"day_of_week":0,"is_working_day":true,"opens_at":"09:00","closes_at":"18:00","break_starts_at":"14:00","break_ends_at":"15:00"},{"day_of_week":5,"is_working_day":false}]}}}},"responses":{"200":{"description":"Horario actualizado","content":{"application/json":{"schema":{"type":"object","properties":{"days_updated":{"type":"integer"},"days":{"type":"array","items":{"type":"object","properties":{"distributor_id":{"type":"string","format":"uuid"},"day_of_week":{"type":"integer"},"is_working_day":{"type":"boolean"},"opens_at":{"type":["string","null"]},"closes_at":{"type":["string","null"]},"break_starts_at":{"type":["string","null"]},"break_ends_at":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/calendar/overrides":{"put":{"tags":["Calendario"],"summary":"Fijar la excepción de un día","description":"Un cierre por puente, una apertura extraordinaria o un horario distinto para una fecha concreta.\n\nLa fecha va en el CUERPO y no en la ruta a propósito: la clave real de una excepción es `(cuenta, fecha)`, así que esta operación es un reemplazo sobre esa clave. Fijar dos veces el cierre del mismo día deja una excepción, no dos, y no hace falta consultar antes si ya existía.\n\n## Reglas\n\n- `closed` ⇒ **sin** `opens_at` ni `closes_at`. Mandar horas en un día cerrado es una contradicción y devuelve 400 en vez de ignorarlas en silencio.\n- `open` y `custom_hours` ⇒ las dos horas presentes, apertura antes que cierre.\n- Una fecha que no existe (`2026-02-31`) devuelve 400, no un error interno.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera:** la operación se resuelve sobre una clave natural única, así que reenviarla tras un timeout de red deja exactamente el mismo estado y no puede duplicar nada.","operationId":"setCalendarOverride","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["date","override_type"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"date":{"type":"string","format":"date","example":"2026-12-25"},"override_type":{"type":"string","enum":["closed","open","custom_hours"]},"opens_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"closes_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"break_starts_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"break_ends_at":{"type":["string","null"],"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:30","description":"Hora en formato `HH:mm`, 24 h."},"reason":{"type":["string","null"],"maxLength":500,"example":"Navidad"}}},"example":{"date":"2026-12-25","override_type":"closed","reason":"Navidad"}}}},"responses":{"200":{"description":"Excepción fijada","content":{"application/json":{"schema":{"type":"object","properties":{"override":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"date":{"type":"string","format":"date"},"override_type":{"type":"string","enum":["closed","open","custom_hours"]},"opens_at":{"type":["string","null"]},"closes_at":{"type":["string","null"]},"break_starts_at":{"type":["string","null"]},"break_ends_at":{"type":["string","null"]},"reason":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}},"delete":{"tags":["Calendario"],"summary":"Quitar la excepción de un día","description":"El día vuelve a regirse por el horario semanal.\n\n**Idempotente sin cabecera:** borrar una excepción que no existe deja el mismo estado y responde **200 con `removed: false`**, no 404 — para un cliente que reintenta, «ya no está» es éxito, no error.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","operationId":"deleteCalendarOverride","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["date"],"properties":{"date":{"type":"string","format":"date","example":"2026-12-25"}}}}}},"responses":{"200":{"description":"Excepción retirada (o no existía)","content":{"application/json":{"schema":{"type":"object","properties":{"date":{"type":"string","format":"date"},"removed":{"type":"boolean","description":"`false` cuando no había excepción para esa fecha."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/WriteDisabled"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/sequences/{sequence_id}":{"patch":{"tags":["Secuencias"],"summary":"Editar una secuencia de email","description":"Actualización parcial. `steps` se **reemplaza entero**, no se fusiona.\n\n### Activar (`status: \"active\"`)\n\n`active` es el estado con el que el sistema empieza a mandar correo real. Se concede por API, y para concederlo la secuencia tiene que ser **honesta**: sus pasos deben caber en los topes del motor, o se rechaza con un 400 que dice el número exacto.\n\n- Como mucho **5 pasos** (el motor no envía más por inscripción).\n- Del segundo paso en adelante, `delay_hours` **≥ 24**.\n\nNo es burocracia: sin esa comprobación una secuencia de doce pasos a dos horas se guardaría encantada, el panel la enseñaría así, y el motor mandaría cinco correos a un día de distancia. Lo configurado y lo enviado no pueden divergir en silencio.\n\nEl primer paso queda fuera del tope de 24 h: su `delay_hours` cuenta desde la inscripción, no desde un correo anterior, así que `0` (seguimiento inmediato tras la llamada) es válido.\n\n### Cuándo deja de enviar\n\nEl motor comprueba esto **antes de cada paso**, y ninguna de las comprobaciones se puede desactivar:\n\n| Situación | Qué pasa | Estado de la inscripción |\n|---|---|---|\n| El contacto **responde** | Para la secuencia entera | `replied` |\n| El contacto se **da de baja** | Para y queda suprimido | `unsubscribed` |\n| El contacto está **suprimido** (lista de exclusión, opt-out, `do_not_email`) | No recibe **ni el primer paso** | `unsubscribed` |\n| La secuencia sale de `active` | Para hasta que se reactive | `paused` |\n| Se llegó al **paso 5** | Fin normal | `completed` |\n| Hace **menos de 24 h** del último correo a esa persona | Se **aplaza**, no se cancela | `active` |\n\nEl tope de 24 h cuenta el último correo que recibió esa persona **de tu cuenta**, venga de la secuencia que venga o de una campaña: dos secuencias solapadas no pueden escribirle dos veces el mismo día.\n\n**Todo correo lleva enlace de baja.** Si la plantilla no incluye `{{unsubscribe_url}}`, el motor añade un pie con el enlace. No es configurable.\n\n⚠ **Reemplazar `steps` en una secuencia con gente dentro tiene efecto:** las inscripciones guardan su posición como índice del array. Acortar la lista completa a quien ya iba por detrás del nuevo final; reordenarla cambia qué correo recibe cada persona a continuación. No se bloquea (reorganizar una secuencia en reposo es legítimo), pero no se ve en la respuesta y por eso se dice aquí.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente sin cabecera:** `steps` se reemplaza, no se acumula.","operationId":"updateSequence","parameters":[{"name":"sequence_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Todos los campos son opcionales; hace falta al menos uno. `distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"name":{"type":"string","maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"steps":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}},"status":{"type":"string","enum":["draft","active","paused","archived"],"description":"`active` arma el envío de correo. Se admite, pero exige que la secuencia quepa en los topes del motor (≤ 5 pasos, ≥ 24 h entre pasos)."}}}}}},"responses":{"200":{"description":"Secuencia actualizada","content":{"application/json":{"schema":{"type":"object","properties":{"sequence":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","archived"],"description":"`active` es el estado con el que el sistema envía correo de verdad. Se puede fijar por API, pero la secuencia tiene que caber en los topes del motor (ver `PATCH`)."},"steps":{"type":"array","items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}},"total_enrolled":{"type":"integer"},"total_completed":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}},"delete":{"tags":["Secuencias"],"summary":"Archivar una secuencia de email","description":"**Archiva, no borra.** `status` pasa a `archived` y las inscripciones que seguían activas se pausan en el acto.\n\nNo hay borrado físico a propósito: las inscripciones cuelgan de la secuencia con `ON DELETE CASCADE`, así que un borrado real se llevaría por delante el historial de a quién se le escribió y por qué. Ese historial es la prueba de que se respetó una baja.\n\nLo que **no** toca son las inscripciones ya cerradas por una regla de parada: un `replied` o un `unsubscribed` no se convierten en `paused`, porque `paused` es reanudable y esos dos no deben serlo nunca.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente:** archivar lo ya archivado devuelve 200 y el mismo estado.","operationId":"archiveSequence","parameters":[{"name":"sequence_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Secuencia archivada","content":{"application/json":{"schema":{"type":"object","properties":{"sequence":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","archived"],"description":"`active` es el estado con el que el sistema envía correo de verdad. Se puede fijar por API, pero la secuencia tiene que caber en los topes del motor (ver `PATCH`)."},"steps":{"type":"array","items":{"type":"object","required":["template_id","delay_hours"],"properties":{"order":{"type":"integer","readOnly":true,"description":"Posición del paso. **Lo asigna el servidor** por el orden del array; si lo envías, se ignora."},"template_id":{"type":"string","format":"uuid","description":"Plantilla de email **de tu tenant**. Una plantilla ajena devuelve 404."},"delay_hours":{"type":"integer","minimum":0,"maximum":8760,"description":"Horas de espera antes de este paso. 0 = inmediato."},"condition":{"type":"string","enum":["always","opened","not_opened"],"default":"always","description":"Condición sobre el envío anterior. Un valor fuera de esta lista se rechaza: el worker lo trataría como `always` y enviaría un correo que creías condicionado."},"subject_override":{"type":["string","null"],"maxLength":500,"description":"Asunto que sustituye al de la plantilla solo en este paso."}}}},"total_enrolled":{"type":"integer"},"total_completed":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/sequences/{sequence_id}/enroll":{"post":{"tags":["Secuencias"],"summary":"Inscribir contactos en una secuencia","description":"Inscribe contactos **de tu tenant** en una secuencia de seguimiento.\n\n### La respuesta es un desglose, no un número\n\nAntes de crear cada inscripción se comprueba la supresión completa del contacto — la misma que se aplica al enviar. Quien esté suprimido **no entra**, y la petición no falla por ello: te devuelve qué contactos entraron y cuáles no, con un código de motivo por cada uno.\n\nRechazar la petición entera porque uno de cien contactos está suprimido te obligaría a adivinar cuál, y el motivo por el que alguien está en una lista de exclusión no es información que se publique en un mensaje de error.\n\nMotivos posibles: `contact_do_not_email`, `contact_opt_out`, `suppression_entry`, `exclusion_list`, `unverifiable`, `contact_not_found`, `already_enrolled`, `write_failed`.\n\n### Por qué se comprueba también aquí\n\nInscribir a alguien es prometer que se le va a escribir. Una inscripción creada para quien ya dijo que no queda en la base como intención pendiente, y basta con que alguien reanude inscripciones pausadas en lote para que se convierta en un correo.\n\n### Cuándo deja de enviar\n\nEl motor comprueba esto **antes de cada paso**, y ninguna de las comprobaciones se puede desactivar:\n\n| Situación | Qué pasa | Estado de la inscripción |\n|---|---|---|\n| El contacto **responde** | Para la secuencia entera | `replied` |\n| El contacto se **da de baja** | Para y queda suprimido | `unsubscribed` |\n| El contacto está **suprimido** (lista de exclusión, opt-out, `do_not_email`) | No recibe **ni el primer paso** | `unsubscribed` |\n| La secuencia sale de `active` | Para hasta que se reactive | `paused` |\n| Se llegó al **paso 5** | Fin normal | `completed` |\n| Hace **menos de 24 h** del último correo a esa persona | Se **aplaza**, no se cancela | `active` |\n\nEl tope de 24 h cuenta el último correo que recibió esa persona **de tu cuenta**, venga de la secuencia que venga o de una campaña: dos secuencias solapadas no pueden escribirle dos veces el mismo día.\n\n**Todo correo lleva enlace de baja.** Si la plantilla no incluye `{{unsubscribe_url}}`, el motor añade un pie con el enlace. No es configurable.\n\n`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.\n\n**Idempotente:** repetir la petición devuelve `already_enrolled` para quien ya estaba, sin reiniciar su posición — reiniciarla le volvería a mandar el primer correo.","operationId":"enrollInSequence","parameters":[{"name":"sequence_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contact_ids"],"description":"`distributor_id` NO es un campo aceptado en ningún nivel del cuerpo: el tenant se deriva SIEMPRE de la API key. Si lo envías, **se ignora** (no se valida y no da error) y la escritura se aplica a tu propio tenant.","properties":{"contact_ids":{"type":"array","minItems":1,"maxItems":200,"items":{"type":"string","format":"uuid"},"description":"Contactos **de tu tenant**. Un id ajeno se devuelve como `contact_not_found`, igual que uno inexistente."}}},"example":{"contact_ids":["9f2c1a34-0000-4000-8000-00000000000a","9f2c1a34-0000-4000-8000-00000000000b"]}}}},"responses":{"200":{"description":"Procesado. Mira `results`: puede haber contactos que no entraron.","content":{"application/json":{"schema":{"type":"object","required":["enrolled","skipped","results"],"properties":{"enrolled":{"type":"integer","description":"Cuántos entraron."},"skipped":{"type":"integer","description":"Cuántos no entraron."},"results":{"type":"array","items":{"type":"object","properties":{"contact_id":{"type":"string","format":"uuid"},"enrolled":{"type":"boolean"},"reason":{"type":"string","description":"Solo cuando `enrolled: false`."}}}}}},"example":{"enrolled":1,"skipped":1,"results":[{"contact_id":"9f2c1a34-0000-4000-8000-00000000000a","enrolled":true},{"contact_id":"9f2c1a34-0000-4000-8000-00000000000b","enrolled":false,"reason":"exclusion_list"}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/calls/dial":{"post":{"tags":["Gasto"],"summary":"Lanzar una llamada saliente","operationId":"dialOutboundCall","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nAbre una llamada desde uno de **tus** números hacia el destino que indiques, atendida por uno de **tus** agentes.\n\n### Qué cuesta y quién paga\n\nEl minutaje de la llamada, a tu precio de venta, cargado al monedero de tu cuenta (o a la cuenta que te factura, si perteneces a una agencia). **Esta respuesta no incluye el coste**: en el momento de abrir la línea todavía no existe. Consúltalo después en `GET /api/v1/calls`, donde aparece ya liquidado.\n\n### Antes de abrir la línea se comprueba\n\n- Que el número y el agente son **tuyos** (si no, `404`).\n- Que el destino está dentro del cerco de países del número, si lo tiene (`403 `destination_not_allowed``).\n- Que tienes **saldo** (`402`), salvo cuentas exentas o de pospago.\n- Que te queda **capacidad de canales** (`429 concurrency_exceeded`). Si la capacidad no se puede verificar en ese instante, la llamada **se deniega**: preferimos que reintentes a sobrepasar lo que tienes contratado.\n\nScope `calls:dial` · función de cuenta `use_outbound`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone_number_id","agent_id","destination"],"properties":{"phone_number_id":{"type":"string","format":"uuid","description":"Número tuyo desde el que se llama."},"agent_id":{"type":"string","format":"uuid","description":"Agente que atiende la llamada. Debe tener asistente de voz creado."},"destination":{"type":"string","description":"Destino en E.164. Se ignoran los espacios.","pattern":"^\\+?\\d{7,15}$"}}},"example":{"phone_number_id":"3f9d1a20-11aa-4bb1-9c22-77e0f1a4b2c3","agent_id":"8c2e4d10-55bb-4cc2-8d33-11a2b3c4d5e6","destination":"+34600111222"}}}},"responses":{"201":{"description":"Llamada iniciada.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"call":{"type":"object","properties":{"id":{"type":"string","description":"Identificador de la llamada. Es el mismo con el que aparecerá en `GET /api/v1/calls` cuando termine."},"agent_id":{"type":"string","format":"uuid"},"phone_number_id":{"type":"string","format":"uuid"},"destination":{"type":"string"},"status":{"type":"string","enum":["initiated"]}}}}},"example":{"call":{"id":"b7c1e2f3-4a5b-6c7d-8e9f-0a1b2c3d4e5f","agent_id":"8c2e4d10-55bb-4cc2-8d33-11a2b3c4d5e6","phone_number_id":"3f9d1a20-11aa-4bb1-9c22-77e0f1a4b2c3","destination":"+34600111222","status":"initiated"}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"402":{"description":"Saldo insuficiente. Recarga el monedero antes de llamar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_balance","message":"Saldo insuficiente."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key), o **todas tus líneas ocupadas** (`concurrency_exceeded`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"concurrency_exceeded","message":"Todas las líneas están ocupadas (5/5)."}}}}},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/campaigns/{campaign_id}/dispatch":{"post":{"tags":["Gasto"],"summary":"Despachar un lote de una campaña","operationId":"dispatchCampaign","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nConstruye la cola de la campaña si aún no existe y **envía un lote**. Para drenar una campaña grande se llama varias veces, **con una `Idempotency-Key` distinta cada vez**: cada lote es una operación propia. La clave protege el reintento de UN lote concreto, no la campaña entera.\n\n### Qué cuesta y quién paga\n\nLos envíos salen por **tu** proveedor de email configurado y consumen **tu** cuota con él. AMAI no factura por envío. Lo que no tiene vuelta atrás es el efecto: un correo enviado no se retira.\n\n### Campañas de voz\n\n**No se despachan, ni aquí ni en el panel.** El producto no tiene dispatcher ni worker para campañas de tipo `call`. Una campaña de voz responde `409 voice_campaigns_unavailable`.\n\nScope `campaigns:dispatch` · función de cuenta `manage_campaigns`.","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"batch_size":{"type":"integer","minimum":1,"maximum":500,"default":50,"description":"Cuántos envíos consume esta llamada."}}},"example":{"batch_size":100}}}},"responses":{"200":{"description":"Lote despachado.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"dispatch":{"type":"object","properties":{"campaign_id":{"type":"string","format":"uuid"},"queued":{"type":"integer","description":"Envíos pendientes en la cola tras construirla."},"sent":{"type":"integer"},"failed":{"type":"integer"}}}}},"example":{"dispatch":{"campaign_id":"1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b","queued":320,"sent":100,"failed":0}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Además de los conflictos de idempotencia:\n- `voice_campaigns_unavailable` — las campañas de voz no están disponibles en el producto.\n- `campaign_type_not_dispatchable` — ese tipo de campaña no se despacha por esta vía.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"voice_campaigns_unavailable","message":"Las campañas de voz (llamadas) todavía no están disponibles. Próximamente."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/billing/topups":{"post":{"tags":["Gasto"],"summary":"Recargar el monedero","operationId":"createBalanceTopup","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nAbre una sesión de pago para añadir saldo al monedero y devuelve su URL. **Esta llamada no cobra nada**: el saldo entra cuando alguien completa el pago y Stripe nos lo confirma.\n\n### Qué cuesta y quién paga\n\nEl importe que pidas, a la tarjeta de tu cuenta. Entra **íntegro** al monedero: AMAI no cobra comisión por recargar.\n\nUn reintento con la misma `Idempotency-Key` devuelve **la misma URL**, no una segunda sesión de pago — sin eso, un cliente con reintentos automáticos podría acabar pagando varias veces la misma recarga.\n\n**El cobro desatendido de una tarjeta ya guardada no se ofrece por API.** Ver la nota del módulo.\n\nScope `billing:write` · función de cuenta `manage_billing`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount"],"properties":{"amount":{"type":"number","description":"Importe a recargar. Los mínimos y máximos son los mismos del panel y dependen de la moneda; un importe fuera de rango responde `400 invalid_amount` con el límite concreto en el mensaje."},"currency":{"type":"string","enum":["USD","EUR"],"description":"⚠ **Hoy la plataforma liquida SIEMPRE en USD.** El campo se acepta por compatibilidad, pero la sesión de pago se abre en dólares pidas lo que pidas. La respuesta devuelve la moneda **real** con la que se cobra (`USD`), no la solicitada: fíate de la respuesta, no de tu petición."}}},"example":{"amount":50,"currency":"EUR"}}}},"responses":{"201":{"description":"Sesión de pago creada. Nada cobrado todavía.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"topup":{"type":"object","properties":{"checkout_url":{"type":"string","format":"uri"},"amount":{"type":"number"},"currency":{"type":"string","enum":["USD","EUR"],"description":"La moneda REAL del cobro. Hoy siempre `USD`."},"expires_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"topup":{"checkout_url":"https://checkout.stripe.com/c/pay/cs_live_a1b2c3","amount":50,"currency":"EUR","expires_at":"2026-07-27T10:00:00.000Z"}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Además de `insufficient_scope` / `capability_required` / `money_opt_in_required`: `demo_account` — las cuentas de demostración no recargan saldo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"demo_account","message":"Las cuentas demo no pueden recargar saldo."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"`idempotency_unavailable` (nada ejecutado) o `payment_provider_unavailable` (Stripe no respondió). **En ninguno de los dos casos se ha cobrado nada.**","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"payment_provider_unavailable","message":"El proveedor de pago no está disponible. No se ha cobrado nada."}}}}}}}},"/api/v1/billing/addons/{addon_type}":{"delete":{"tags":["Gasto"],"summary":"Cancelar un add-on","operationId":"cancelAddon","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\n**Nada, y no devuelve nada.** El add-on sigue activo hasta `ends_at` y no se reembolsa la parte no consumida.\n\nSe aceptan add-ons que ya no están en el catálogo activo: quien contrató uno que después retiramos tiene que poder darlo de baja.\n\nScope `billing:write` · función de cuenta `manage_billing`.","parameters":[{"name":"addon_type","in":"path","required":true,"schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"responses":{"200":{"description":"Cancelación programada.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"addon":{"type":"object","properties":{"addon_type":{"type":"string"},"canceled":{"type":"boolean"},"ends_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"addon":{"addon_type":"concurrency_channels","canceled":true,"ends_at":"2026-08-15T00:00:00.000Z"}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflictos de idempotencia, o `addon_not_cancelable` — no existe o no está activo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"addon_not_cancelable","message":"Suscripción no encontrada"}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/sip-trunks/{trunk_id}":{"patch":{"tags":["Gasto"],"summary":"Modificar un troncal SIP","operationId":"updateSipTrunk","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nModificación parcial: los campos que no mandes no se tocan. Mandar `password` la sustituye; no hay forma de leerla de vuelta.\n\n### Qué cuesta y quién paga\n\n**Nada por sí misma.** Cambia por dónde saldrá el tráfico futuro, que sí lo paga tu cuenta.\n\nUn troncal de otra cuenta responde `404`, nunca `403`: un `403` confirmaría que el identificador existe en otro sitio.\n\nScope `sip-trunks:write` · función de cuenta `create_sip_trunks`.","parameters":[{"name":"trunk_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"sip_host":{"type":"string"},"sip_port":{"type":"integer","minimum":1,"maximum":65535,"default":5060},"username":{"type":["string","null"],"description":"Solo de escritura. **No aparece en ninguna respuesta.** Con `auth_mode: \"credentials\"` es la otra mitad de la credencial del troncal, así que se trata igual que la contraseña: se puede escribir y no se puede leer. Para saber si el troncal tiene credencial configurada está `has_password`.","writeOnly":true},"password":{"type":["string","null"],"description":"Solo de escritura. No aparece en ninguna respuesta.","writeOnly":true},"transport":{"type":"string","enum":["udp","tcp","tls"],"default":"udp"},"auth_mode":{"type":"string","enum":["credentials","ip"],"default":"credentials"},"codecs":{"type":["string","null"]},"notes":{"type":["string","null"]},"is_active":{"type":"boolean"}}},"example":{"is_active":false,"notes":"En mantenimiento"}}}},"responses":{"200":{"description":"Troncal actualizado.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"trunk":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"sip_host":{"type":"string"},"sip_port":{"type":"integer"},"has_password":{"type":"boolean","description":"Si el troncal tiene contraseña configurada. **La contraseña en sí no se devuelve nunca**, por ninguna vía y para ningún rol."},"transport":{"type":"string","enum":["udp","tcp","tls"]},"auth_mode":{"type":"string","enum":["credentials","ip"]},"codecs":{"type":["string","null"]},"is_active":{"type":"boolean"},"notes":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}}},"example":{"trunk":{"id":"5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f","name":"Operador principal","sip_host":"sip.operador.example","sip_port":5060,"has_password":true,"transport":"udp","auth_mode":"credentials","codecs":"OPUS,G722,PCMU,PCMA","is_active":false,"notes":null,"created_at":"2026-07-20T09:12:00.000Z","updated_at":null}}}}},"400":{"description":"`invalid_field` (un valor fuera de rango o de enumerado), `no_fields` (no mandaste ningún campo), o `missing_idempotency_key` / `invalid_idempotency_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"no_fields","message":"No hay ningún campo que actualizar."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}},"delete":{"tags":["Gasto"],"summary":"Eliminar un troncal SIP","operationId":"deleteSipTrunk","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\n### Qué cuesta y quién paga\n\n**Nada.** Pero deja sin salida a las llamadas futuras que dependieran de este troncal. Las llamadas en curso no se cortan.\n\nScope `sip-trunks:write` · función de cuenta `create_sip_trunks`.","parameters":[{"name":"trunk_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"responses":{"200":{"description":"Troncal eliminado.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"trunk":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deleted":{"type":"boolean","enum":[true]}}}}},"example":{"trunk":{"id":"5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f","deleted":true}}}}},"400":{"description":"Petición inválida, o falta / es inválida la cabecera `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_idempotency_key","message":"Se requiere la cabecera `Idempotency-Key`."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/agency/subaccounts":{"post":{"tags":["Gasto"],"summary":"Crear una subcuenta","operationId":"createSubaccount","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nCrea una cuenta hija con su propio usuario propietario. La jerarquía sale de **tu** cuenta: la nueva cuelga siempre de ti.\n\n### Qué cuesta y quién paga\n\n**El alta no cobra nada.** Pero la subcuenta puede consumir desde ese momento, y su consumo se factura a quien indique la jerarquía de facturación — por defecto ella misma.\n\n### Qué NO acepta esta operación, y por qué\n\nNo acepta `plan_slug`, ni `capabilities`, ni `account_kind`. **La subcuenta nace con el plan por defecto de su rol.** El plan decide qué puede hacer una cuenta, así que poder elegirlo al crearla sería conceder funciones de pago sin pagarlas, de forma automatizable. El plan de la subcuenta se cambia después por el camino de cobro, desde su propia cuenta.\n\n### Qué NO devuelve\n\nNi la contraseña, ni credenciales SIP. Esta operación **no aprovisiona telefonía**: si la subcuenta necesita troncal, se pide con la clave de la subcuenta.\n\n### Qué roles puedes crear\n\nDepende de tu rol **y** de las funciones de tu cuenta (`create_client`, `create_agency`, `create_partner`). Un rol que no puedas crear responde `403 role_not_allowed` — distinto de `400 invalid_role`, que es un valor que no existe.\n\nScope `agency:write` · función de cuenta `create_subaccounts`.","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["company_name","contact_name","email","password"],"properties":{"company_name":{"type":"string"},"contact_name":{"type":"string"},"email":{"type":"string","format":"email"},"password":{"type":"string","writeOnly":true,"description":"Contraseña del propietario de la nueva cuenta. Se valida con la misma política que el panel; si no la cumple, `400 weak_password` con el motivo concreto."},"role":{"type":"string","enum":["client","agency","partner"],"default":"client"}}},"example":{"company_name":"Clínica Norte","contact_name":"Marta Ruiz","email":"marta@clinicanorte.example","password":"…","role":"client"}}}},"responses":{"201":{"description":"Subcuenta creada.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"subaccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"company_name":{"type":"string"},"contact_name":{"type":"string"},"email":{"type":"string","format":"email"},"role":{"type":"string","enum":["client","agency","partner"]},"status":{"type":"string","enum":["active","suspended"]},"plan_slug":{"type":"string","description":"El plan por defecto del rol. **No se puede elegir**: ver la nota de la operación de alta."},"created_at":{"type":"string","format":"date-time"}}}}},"example":{"subaccount":{"id":"7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d","company_name":"Clínica Norte","contact_name":"Marta Ruiz","email":"marta@clinicanorte.example","role":"client","status":"active","plan_slug":"free","created_at":"2026-07-26T09:30:00.000Z"}}}}},"400":{"description":"`missing_field`, `weak_password`, `invalid_role` (valor que no existe), o `missing_idempotency_key` / `invalid_idempotency_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"weak_password","message":"La contraseña debe tener al menos 8 caracteres."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Además de `insufficient_scope` / `capability_required` / `money_opt_in_required`: `role_not_allowed` — tu cuenta no puede crear subcuentas de ese tipo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"role_not_allowed","message":"Tu cuenta no puede crear subcuentas de tipo \"partner\"."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflictos de idempotencia, o `email_taken` — ese correo ya tiene usuario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"email_taken","message":"Ya existe un usuario con ese email."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/agency/subaccounts/{subaccount_id}":{"patch":{"tags":["Gasto"],"summary":"Activar o suspender una subcuenta","operationId":"updateSubaccountStatus","description":"**Esta operación pertenece a la superficie de GASTO.** Además de una API key válida con el scope correspondiente y de que el plan de la cuenta incluya la función, exige dos cosas que ninguna otra parte de la API pide:\n\n1. **Un interruptor propio del entorno** (`PUBLIC_API_MONEY_ENABLED`, además de `PUBLIC_API_WRITE_ENABLED`). Si falta cualquiera de los dos, la respuesta es **404** — la misma que daría una ruta que no existe.\n2. **Consentimiento explícito de tu cuenta.** Que nosotros activemos el interruptor no habilita a nadie: cada cuenta se da de alta a mano en esta superficie. Sin ese alta, **403 `money_opt_in_required`**.\n\nY exige la cabecera **`Idempotency-Key`**, que no es opcional. \n\n---\n\nEl freno de mano de la agencia: suspender corta el acceso de la subcuenta y, con él, su capacidad de generar consumo.\n\n### Qué cuesta y quién paga\n\n**Nada.** No emite cargos ni reembolsos, y **no cancela las suscripciones de Stripe** que la subcuenta tuviera contratadas: esas siguen su ciclo. Se dice explícitamente porque «suspender» suena a «dejar de pagar» y no lo es.\n\n### El único campo que se puede tocar\n\n`status`, y nada más. Ni el plan, ni el rol, ni las funciones, ni el saldo. Una agencia que pudiera reescribir el plan de sus subcuentas se estaría concediendo funciones de pago por persona interpuesta.\n\nSolo se aceptan **hijas directas** de tu cuenta. Cualquier otro identificador responde `404`.\n\nScope `agency:write` · función de cuenta `create_subaccounts`.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":true,"description":"Manda un identificador único y estable por operación (un UUID vale) y **repítelo tal cual en cualquier reintento**: es lo que garantiza que un timeout de red no te cobre dos veces. Reintentar con la misma clave y el mismo cuerpo devuelve la respuesta guardada de la primera vez, con la cabecera `Idempotency-Replayed: true`, y **no vuelve a ejecutar nada**. Reutilizar la clave con un cuerpo distinto es un error del cliente y responde **409 `idempotency_conflict`**: usa una clave nueva para una operación nueva.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_.:-]+$"},"example":"9f1c2b7a-3d44-4a11-9b0e-6a2d8c5e1f30"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["active","suspended"]}}},"example":{"status":"suspended"}}}},"responses":{"200":{"description":"Estado actualizado.","headers":{"Idempotency-Replayed":{"description":"`true` cuando la respuesta viene del registro de idempotencia y no se re-ejecutó.","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"type":"object","properties":{"subaccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["active","suspended"]}}}}},"example":{"subaccount":{"id":"7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d","status":"suspended"}}}}},"400":{"description":"`invalid_status` (solo `active` o `suspended`), o `missing_idempotency_key` / `invalid_idempotency_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_status","message":"\"status\" debe ser: active o suspended."}}}}},"401":{"description":"Sin API key válida. **La cookie de sesión del panel NO sirve en esta superficie**: las operaciones de gasto solo se hacen con `Authorization: Bearer amai_…`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Las operaciones de gasto exigen una API key."}}}}},"403":{"description":"Denegado, y el código dice **dónde se arregla**:\n- `insufficient_scope` — la key no tiene el scope. Lo arregla quien emitió la key.\n- `capability_required` — el plan de la cuenta no incluye la función. Lo arregla el plan.\n- `money_opt_in_required` — la cuenta no está dada de alta en la superficie de gasto. Lo activa tu distribuidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"money_opt_in_required","message":"Esta cuenta no tiene habilitadas las operaciones de gasto por API (\"calls:dial\")."}}}}},"404":{"description":"El recurso no existe, **o pertenece a otra cuenta**, **o la superficie de gasto no está activada en este entorno**. Las tres comparten respuesta a propósito: distinguirlas revelaría la existencia de recursos ajenos y la topología del despliegue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"409":{"description":"Conflicto de idempotencia:\n- `idempotency_conflict` — esa clave ya se usó en tu cuenta **con otro cuerpo**.\n- `idempotency_in_progress` — una operación con esa clave sigue en vuelo. Reintenta en unos segundos **con la misma clave**; cambiarla la ejecutaría dos veces.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_conflict","message":"Esa `Idempotency-Key` ya se usó en esta cuenta con un cuerpo distinto."}}}}},"429":{"description":"Presupuesto de peticiones agotado (~60/min por API key)."},"500":{"description":"Error interno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"503":{"description":"El registro de idempotencia no está disponible, así que **la operación no se ha ejecutado y no se ha cobrado nada**. Reintenta con la MISMA `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"idempotency_unavailable","message":"El registro de idempotencia no está disponible."}}}}}}}},"/api/v1/numbers/{number_id}":{"patch":{"tags":["Telefonía"],"summary":"Renombrar un número del inventario","description":"Cambia la etiqueta de un número. Es el nombre con el que el cliente lo identifica en su propia lista, y poder escribirlo desde un CRM es el caso de uso de «inventario por API».\n\n### El único campo editable es `label`, y la lista de lo que NO se puede es lo importante\n\nEl panel deja cambiar de un número mucho más: el agente que lo atiende, el destino de entrada, el perfil, los países permitidos. **Nada de eso se abre por API**, y todo por la misma causa:\n\n> En este producto el **enrutado de entrada de un número es manual**. No hay un > controlador que lo cablee solo.\n\nPor eso la compra self-serve de números está capada tras un interruptor: un número comprado y no cableado es un número **pagado y muerto**. Abrir por API la escritura del enrutado sería la misma avería por el otro extremo — una llamada que el cliente cree haber redirigido y que sigue cayendo donde estaba.\n\nAdemás, esos campos del panel **no son escrituras en base de datos**: disparan una sincronización con VAPI y con el plan de marcación de la centralita. Una API pública no origina mutaciones en una centralita en producción sin una decisión explícita, con su propio interruptor. `label` es el único campo del número que no produce ningún efecto fuera de su fila: no toca VAPI, no toca la centralita, no toca dinero.\n\n### Alcance: más estrecho que el de la lectura\n\n`GET /api/v1/numbers` incluye los números de subcuentas que esta cuenta **factura** (`scope: \"delegated\"`). Escribir es otra cosa: pagar la factura de un número no da derecho a renombrárselo a su dueño. Un número de una subcuenta responde **404**, igual que uno de un tercero.\n\n### Requisitos de activación\n\nEsta operación pertenece a la superficie de escritura y necesita `PUBLIC_API_WRITE_ENABLED` activo en la plataforma. **Hoy está apagado**: mientras lo esté, la ruta responde `404 not_found`, igual que si no existiera. Además, la cuenta debe tener la capacidad `manage_dids` y la clave el scope `numbers:write`.","operationId":"updateNumber","security":[{"bearerAuth":[]}],"parameters":[{"name":"number_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"El `id` del número, tal y como lo devuelve `GET /api/v1/numbers`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"properties":{"label":{"type":["string","null"],"maxLength":120,"example":"Centralita Madrid","description":"El nombre visible del número. **`null` quita la etiqueta**; una cadena de solo espacios se trata igual que `null`, para no dejar en la lista un nombre en blanco que el cliente ya no sabe identificar."}}},"example":{"label":"Centralita Madrid"}}}},"responses":{"200":{"description":"El número, ya renombrado.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","number","label"],"properties":{"id":{"type":"string","format":"uuid"},"number":{"type":"string","example":"+34919469552"},"label":{"type":["string","null"],"example":"Centralita Madrid"}}}}},"example":{"data":{"id":"3c7e51a8-9d02-4f66-b1aa-5e8c1d3f7042","number":"+34919469552","label":"Centralita Madrid"}}}}},"400":{"description":"Cuerpo o parámetro mal formado. Un campo desconocido **no se ignora en silencio**: si no viene `label`, la respuesta es `no_fields` y no un 200 que no cambió nada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"no_fields":{"summary":"No venía ningún campo editable","value":{"error":{"code":"no_fields","message":"No hay ningún campo que actualizar. El único campo editable es \"label\"."}}},"invalid_field":{"summary":"`number_id` no es un UUID, o `label` excede el máximo","value":{"error":{"code":"invalid_field","message":"\"number_id\" debe ser un UUID."}}},"invalid_json":{"summary":"El cuerpo no es JSON válido","value":{"error":{"code":"invalid_json","message":"El cuerpo debe ser JSON válido."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"No existe, o pertenece a otro tenant. **Son el mismo código a propósito**: un 403 confirmaría que el identificador es real y convertiría el endpoint en un oráculo con el que enumerar los recursos ajenos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"No existe un recurso con ese identificador."}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}},"/api/v1/team/invites/{invite_id}":{"delete":{"tags":["Equipo"],"summary":"Revocar una invitación pendiente","description":"Cancela una invitación que todavía **no se ha canjeado**.\n\nEs la única escritura que esta API abre sobre el equipo, y la razón es una propiedad concreta: revocar solo QUITA un acceso que aún no se ha ejercido. No concede rol, no crea identidad y no emite credencial — al contrario que invitar, cambiar el rol de un miembro o reemitirle la contraseña, que no se abren y no se abrirán por esta vía.\n\n**Solo actúa sobre invitaciones pendientes.** Una ya aceptada devuelve `404`: borrarla no quitaría el acceso —el miembro ya está dentro— y sí borraría el rastro de cómo entró. Para dar de baja a un miembro activo hay que hacerlo desde el panel, con una sesión y un permiso de persona detrás.\n\n**Requiere el interruptor `PUBLIC_API_WRITE_ENABLED` y una clave con el scope `invites:revoke`.** Con el interruptor apagado la operación responde `404`, igual que si no existiera: es deliberado, para que un sondeo no pueda distinguir «apagado» de «no desplegado». Además exige la capacidad `manage_team` (ver el aviso de las lecturas).\n\n**Aviso de trazabilidad:** esta revocación **no deja registro en el log de auditoría**. El registro exige un usuario real y quien llama es una credencial de máquina; está declarado como pendiente y debería cerrarse antes de encender el interruptor de escritura.","operationId":"revokeTeamInvite","parameters":[{"name":"invite_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Identificador de la invitación, tal y como lo devuelve `GET /api/v1/team/invites`."}],"responses":{"200":{"description":"Invitación revocada","content":{"application/json":{"schema":{"type":"object","required":["revoked","id"],"properties":{"revoked":{"type":"boolean","enum":[true]},"id":{"type":"string","format":"uuid"}}},"example":{"revoked":true,"id":"6b1e2c40-3a5f-4d21-9c88-0e1f2a3b4c5d"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/api-keys/{key_id}/rotate":{"post":{"tags":["Claves de API"],"summary":"Rotar la propia clave de API","description":"Emite una credencial nueva con **el mismo nombre y los mismos permisos**, y revoca la actual. Convierte la rotación de credenciales en una llamada en vez de un procedimiento manual con capturas de pantalla.\n\n## Solo puedes rotarte a ti mismo\n\n`key_id` tiene que ser **el identificador de la clave con la que estás llamando** (`api_key.id`, el que ves en el panel; nunca el valor del secreto). Cualquier otro identificador responde `404` — incluidas las demás claves de tu propia cuenta.\n\nEs deliberado y es toda la seguridad de esta operación: el número de credenciales de la cuenta **no cambia nunca** por esta ruta, así que una clave filtrada no puede fabricarse una segunda y quedarse dentro. Crear, renombrar y revocar el resto de claves se hace desde el panel, donde hay una persona identificada detrás.\n\nLos `404` de «no es tuya», «es de otra cuenta» y «no existe» son idénticos: un código distinto confirmaría qué identificadores son reales.\n\n## La clave anterior muere en el acto\n\n**No hay periodo de gracia.** En cuanto esta llamada responde `201`, la credencial anterior deja de autenticar. Se rota porque una credencial se ha filtrado, y una ventana de solapamiento mantendría viva justo la que se quiere matar.\n\nDespliega el valor que devuelve esta respuesta **antes** de volver a llamar a la API con el antiguo. El corte es de una sola llamada: quien rota es quien despliega.\n\n## El valor se entrega una sola vez\n\n`key` viaja **únicamente** en esta respuesta. Lo que se guarda es su hash y no hay ninguna operación en toda la API que devuelva el valor de una clave ya emitida. Si lo pierdes, vuelve a rotar.\n\nLa operación queda registrada en el histórico de auditoría de la cuenta (`api_key.rotate`).\n\nRequiere `PUBLIC_API_WRITE_ENABLED`; si no, 404.","operationId":"rotateApiKey","parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"El id de **tu propia** clave, el que aparece en `/app/api-keys`. No es el secreto."}],"responses":{"201":{"description":"Credencial rotada. La anterior ya no autentica desde este mismo instante.","content":{"application/json":{"schema":{"type":"object","required":["api_key","key","rotated_from"],"properties":{"api_key":{"type":"object","description":"La clave nueva. Nunca incluye el secreto ni su hash.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Heredado de la clave anterior."},"key_prefix":{"type":"string","example":"amai_3f9"},"key_suffix":{"type":"string","example":"b7d1"},"scopes":{"type":["array","null"],"items":{"type":"string"},"description":"Heredados EXACTOS de la clave anterior: ni uno más (rotar no escala) ni uno menos (rotar no rompe la integración que salva)."},"is_active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}},"key":{"type":"string","description":"**El secreto, y la única vez que se envía.** Guárdalo ahora: no se puede volver a consultar.","example":"amai_0000000000000000000000000000000000000000000000ff"},"rotated_from":{"type":"object","description":"La credencial que acaba de quedar revocada.","properties":{"id":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Prohibido, por una de dos razones que se distinguen por el `code`:\n\n- `insufficient_scope`: tu API key no lleva el permiso de escritura que esta operación exige. Lo arregla quien emitió la clave.\n- `capability_required`: el plan de la cuenta no incluye esta función. **No se arregla con otra API key**: habla con tu distribuidor para habilitarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sinScope":{"summary":"La clave no tiene permiso de escritura","value":{"error":{"code":"insufficient_scope","message":"Esta API key es de solo lectura. Solicita al distribuidor una key con permiso de escritura."}}},"sinCapacidad":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"manage_campaigns\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"La escritura pública está desactivada (`PUBLIC_API_WRITE_ENABLED`), **o** `key_id` no es el de la clave que llama. Los dos casos responden lo mismo a propósito.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"No existe una clave con ese identificador"}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Error interno. La operación no se ha aplicado; puedes reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}}}}},"/api/v1/subaccounts/{subaccount_id}/branding/verify-domain":{"post":{"tags":["Marca blanca"],"summary":"Verificar el dominio propio contra el DNS","operationId":"verifySubaccountDomain","description":"El **único** camino por el que `custom_domain_verified` llega a `true`.\n\nResuelve por DNS el registro\n\n```\nTXT  _amai-verify.<dominio>  →  amai-verify=<account_id>\n```\n\ny solo si está publicado escribe el sello. Si no está, responde `200` con `verified: false`, el registro que hace falta y lo que el DNS devolvió de verdad: un dominio sin propagar no es un error, es el estado normal de los primeros minutos.\n\n**No acepta el dominio en el cuerpo.** Verifica el que la cuenta tiene guardado. Si se aceptara uno, el camino evidente sería verificar un dominio propio y luego cambiar `custom_domain` conservando el sello — por eso las dos mitades van juntas: aquí no se elige el dominio, y el `PATCH` que lo cambia revoca la verificación.\n\nEl token es el id de la cuenta **destino**, así que un sello no se hereda: el valor esperado es distinto para cada cuenta.\n\nSin cuerpo. Scope `branding:write` · exige `PUBLIC_API_WRITE_ENABLED`.","parameters":[{"name":"subaccount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Una cuenta que cuelgue de la tuya a cualquier profundidad, **o la tuya propia**. Cualquier otra —tu padre, una hermana, una ajena, o una que no existe— responde `404` con el mismo cuerpo: no se confirma que exista."}],"responses":{"200":{"description":"Resultado de la comprobación. `verified: false` NO es un error: es que el registro todavía no está publicado o no ha propagado.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["verified","domain","record","found"],"properties":{"verified":{"type":"boolean"},"domain":{"type":"string","example":"panel.miempresa.com"},"record":{"type":["object","null"],"description":"El registro TXT que hay que publicar para verificar el dominio guardado.","properties":{"type":{"type":"string","enum":["TXT"]},"host":{"type":"string","example":"_amai-verify.panel.miempresa.com"},"value":{"type":"string","example":"amai-verify=8e5af4de-0000-0000-0000-000000000000"}}},"found":{"type":"array","items":{"type":"string"},"description":"Los TXT que el DNS devolvió. Es lo único depurable sin su zona."}}}}}}}},"400":{"description":"Parámetro mal formado. **Un parámetro inválido nunca se ignora**: ignorarlo devolvería datos de otro periodo o sin el filtro pedido, con un 200 delante.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"invalid_date":{"summary":"Fecha con formato inválido","value":{"error":{"code":"invalid_date","message":"\"start_date\" debe ser ISO-8601: 2026-07-01 o 2026-07-01T00:00:00Z."}}},"invalid_range":{"summary":"Rango invertido o demasiado largo","value":{"error":{"code":"invalid_range","message":"El rango máximo por petición es de 366 días. Divide la consulta."}}},"invalid_field":{"summary":"Valor no permitido en un filtro o en la paginación","value":{"error":{"code":"invalid_field","message":"\"limit\" debe ser un entero entre 1 y 500."}}}}}}},"401":{"description":"Falta la credencial, no es válida, o ha sido revocada.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"unauthorized","message":"Falta o no es válida la credencial. Usa `Authorization: Bearer amai_<key>`."}}}}},"403":{"description":"Denegado. O la clave no lleva el scope que la operación exige, o el plan de la cuenta no incluye la función. Son dos problemas distintos y se arreglan en sitios distintos.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"examples":{"insufficient_scope":{"summary":"La API key no tiene el scope","value":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"billing:read\". Solicita al distribuidor una key con ese scope."}}},"capability_required":{"summary":"El plan de la cuenta no incluye la función","value":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función (\"view_billing\"). No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}}}},"404":{"description":"El interruptor `PUBLIC_API_WRITE_ENABLED` está apagado, **o** la cuenta no cuelga de la tuya. Los dos casos responden lo mismo a propósito.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"not_found","message":"Subcuenta no encontrada"}}}}},"429":{"description":"Presupuesto agotado: 60 peticiones por minuto **y clave** (no por IP). La respuesta trae `Retry-After` y `X-RateLimit-Reset` en segundos. Reintenta con backoff.","headers":{"Retry-After":{"description":"Segundos que faltan para recuperar presupuesto.","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Igual que `Retry-After`. Se envían los dos por compatibilidad.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"rate_limited","message":"Has superado el límite de peticiones. Reintenta en 37 s."}}}}},"500":{"description":"Error inesperado del servidor. El detalle queda en nuestros registros y NUNCA en la respuesta.","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"}}}}},"example":{"error":{"code":"internal_error","message":"Error al procesar la petición."}}}}}}}}},"webhooks":{"whatsapp.message.received":{"post":{"tags":["WhatsApp"],"summary":"Mensaje de WhatsApp entrante (push)","description":"Si configuras un `custom_webhook_url` en el tenant y te suscribes al evento, AMAI hace POST a tu URL en cada mensaje entrante. Cabeceras: `X-AMAI-Event`, `X-AMAI-Delivery`, `X-AMAI-Attempt`, `X-AMAI-Signature: sha256=<hmac>` (HMAC-SHA256 del cuerpo crudo con tu webhook secret). Reintentos hasta 3 veces con backoff 1s/5s/30s; un 4xx los corta.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"whatsapp.message.received"},"timestamp":{"type":"string","format":"date-time"},"data":{"$ref":"#/components/schemas/InboundMessage"}}}}}},"responses":{"200":{"description":"Responde 2xx en < 30s para confirmar recepción."}}}},"call.ended":{"post":{"tags":["Llamadas"],"summary":"Una llamada ha terminado (push)","description":"Si configuras una URL de webhook en tu tenant y te suscribes a este evento, AMAI hace `POST` a tu URL cuando ocurre.\n\n**Cabeceras:** `X-AMAI-Event` (el evento), `X-AMAI-Delivery` (id único del envío, úsalo para descartar duplicados), `X-AMAI-Attempt` (número de intento, empieza en 1) y `X-AMAI-Signature: sha256=<hmac>` — HMAC-SHA256 **del cuerpo crudo** con tu webhook secret. Verifícala antes de fiarte del contenido, y compara en tiempo constante.\n\n**Reintentos:** hasta 3, con esperas de 1s, 5s y 30s. Un `4xx` tuyo corta los reintentos (lo tratamos como rechazo definitivo); un `5xx` o un fallo de red los provoca. Tiempo máximo de espera por intento: 10s.\n\n**Tu endpoint debe ser público.** Se rechaza cualquier destino que resuelva a una dirección interna o de bucle local, y se vuelve a comprobar en cada redirección.\n\nSe emite al cerrarse la llamada, tanto por la centralita como por la ingesta de CDR.\n\n**El coste no viene en este evento.** `rated: false` significa que la tarificación aún no ha corrido. Para el importe, consulta `GET /api/v1/calls` pasados unos segundos.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallWebhookPayload"},"example":{"event":"call.ended","timestamp":"2026-07-26T04:10:00.000Z","data":{"cdr_id":"0f2b1a64-0000-0000-0000-1c2d3e4f5a6b","fs_uuid":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","direction":"outbound","caller_id_number":"+573501234567","destination_number":"+34600111222","duration":72,"billsec":66,"hangup_cause":"NORMAL_CLEARING","rated":false}}}}},"responses":{"200":{"description":"Responde 2xx en < 30s para confirmar recepción."}}}},"call.failed":{"post":{"tags":["Llamadas"],"summary":"Una llamada no llegó a conectar (push)","description":"Si configuras una URL de webhook en tu tenant y te suscribes a este evento, AMAI hace `POST` a tu URL cuando ocurre.\n\n**Cabeceras:** `X-AMAI-Event` (el evento), `X-AMAI-Delivery` (id único del envío, úsalo para descartar duplicados), `X-AMAI-Attempt` (número de intento, empieza en 1) y `X-AMAI-Signature: sha256=<hmac>` — HMAC-SHA256 **del cuerpo crudo** con tu webhook secret. Verifícala antes de fiarte del contenido, y compara en tiempo constante.\n\n**Reintentos:** hasta 3, con esperas de 1s, 5s y 30s. Un `4xx` tuyo corta los reintentos (lo tratamos como rechazo definitivo); un `5xx` o un fallo de red los provoca. Tiempo máximo de espera por intento: 10s.\n\n**Tu endpoint debe ser público.** Se rechaza cualquier destino que resuelva a una dirección interna o de bucle local, y se vuelve a comprobar en cada redirección.\n\nMismo cuerpo que `call.ended`. La diferencia es cómo se clasifica: una llamada es `call.failed` cuando **no terminó con `NORMAL_CLEARING` y además no facturó segundos**. Si colgaron enseguida pero hubo conversación facturada, llega como `call.ended`.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallWebhookPayload"},"example":{"event":"call.failed","timestamp":"2026-07-26T04:10:00.000Z","data":{"cdr_id":"0f2b1a64-0000-0000-0000-1c2d3e4f5a6b","fs_uuid":"3bf9e260-0b0f-41ae-92d0-b0440b226ac9","direction":"outbound","caller_id_number":"+573501234567","destination_number":"+34600111222","duration":18,"billsec":0,"hangup_cause":"NO_ANSWER","rated":false}}}}},"responses":{"200":{"description":"Responde 2xx en < 30s para confirmar recepción."}}}},"agent.created":{"post":{"tags":["Utilidades"],"summary":"Se ha creado un agente (push)","description":"Si configuras una URL de webhook en tu tenant y te suscribes a este evento, AMAI hace `POST` a tu URL cuando ocurre.\n\n**Cabeceras:** `X-AMAI-Event` (el evento), `X-AMAI-Delivery` (id único del envío, úsalo para descartar duplicados), `X-AMAI-Attempt` (número de intento, empieza en 1) y `X-AMAI-Signature: sha256=<hmac>` — HMAC-SHA256 **del cuerpo crudo** con tu webhook secret. Verifícala antes de fiarte del contenido, y compara en tiempo constante.\n\n**Reintentos:** hasta 3, con esperas de 1s, 5s y 30s. Un `4xx` tuyo corta los reintentos (lo tratamos como rechazo definitivo); un `5xx` o un fallo de red los provoca. Tiempo máximo de espera por intento: 10s.\n\n**Tu endpoint debe ser público.** Se rechaza cualquier destino que resuelva a una dirección interna o de bucle local, y se vuelve a comprobar en cada redirección.\n\nSe emite al crear un agente, tanto desde el constructor conversacional como desde el alta directa.\n\n**No hay un `agent.updated` equivalente**: ese evento se puede contratar pero no se emite nunca. Ver la nota de eventos inactivos en la descripción de la API.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentCreatedWebhookPayload"},"example":{"event":"agent.created","timestamp":"2026-07-26T04:10:00.000Z","data":{"agent_id":"0f2b1a64-0000-0000-0000-1c2d3e4f5a6b","name":"Recepción","industry":"clinica","ai_provider":"vapi","status":"draft"}}}}},"responses":{"200":{"description":"Responde 2xx en < 30s para confirmar recepción."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"amai_<key>","description":"API key del tenant. Cabecera `Authorization: Bearer amai_...`. Se genera en Ajustes → API Keys."}},"responses":{"BadRequest":{"description":"Petición inválida","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"missing_to","message":"Se requiere el campo \"to\""}}}}},"Unauthorized":{"description":"No autorizado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"unauthorized","message":"Unauthorized"}}}}},"RateLimited":{"description":"Demasiadas peticiones (~60/min). Reintenta con backoff."},"InsufficientScope":{"description":"**Dos motivos distintos comparten este 403.** `insufficient_scope`: a la API key le falta el permiso — lo arregla quien emitió la clave. `capability_required`: **el plan del tenant** no incluye la función — emitir una key nueva no sirve; hay que habilitarla en la cuenta.\n\nLa capacidad es del tenant, no de quien llama: el segundo sale también autenticando por sesión, así que la consola «probar» puede devolverlo con el usuario correctamente logueado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_scope","message":"API key lacks the whatsapp:read scope"}}}}},"UnauthorizedLegacy":{"description":"Falta la credencial o no es válida. **Sobre antiguo**: `error` es una cadena, no un objeto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"UNAUTHORIZED"}}}},"InsufficientScopeLegacy":{"description":"**Dos motivos distintos comparten este 403, y se arreglan en sitios distintos. Mira `code`.**\n\n1. **`insufficient_scope`** — a la API key le falta el permiso que exige esta operación. Lo arregla **quien emitió la clave**: emite una nueva con ese scope. Sobre antiguo: `error` es una cadena.\n2. **`capability_required`** — el **plan del tenant** no incluye esta función. **Emitir una key nueva no sirve de nada**, por muchos scopes que le pongas: hay que habilitar la función en la cuenta. Sobre nuevo: `{error:{code,message}}`.\n\n**La capacidad es del TENANT, no de quien llama.** Por eso el segundo también sale autenticando con sesión: la consola «probar» de la referencia devolverá `capability_required` a un usuario perfectamente logueado y con todos sus permisos, si el plan de su cuenta no cubre esa función. Es correcto, no es un fallo de la API.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenCause"},"example":{"error":"INSUFFICIENT_SCOPE"}}}},"CapabilityRequired":{"description":"**`capability_required`** — el plan del tenant no incluye esta función. No es un problema de la API key: emitir otra no cambia nada. Hay que habilitarla en la cuenta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"capability_required","message":"Tu plan no incluye esta función. No es un problema de la API key: contacta con tu distribuidor para habilitarla en la cuenta."}}}}},"RateLimitedLegacy":{"description":"Has agotado tu presupuesto de **60 peticiones por minuto**, que se cobra **a tu clave** (no a tu dirección IP): el tráfico de otros no te consume cuota, y salir por un NAT compartido no te penaliza.\n\nPor delante hay una segunda barrera, de **600 por minuto y dirección IP**, que existe solo para frenar avalanchas sin credencial. No es tu cuota; si la ves, hay algo raro en tu red.\n\nCabeceras `Retry-After` y `X-RateLimit-Reset`, en segundos. Espera lo que digan y reintenta.\n\n**Fíjate en la forma del cuerpo:** aquí `error` es una **cadena**. Es el sobre legado, y solo lo usan las operaciones cuyo `429` apunta a esta respuesta; en el resto de la API el mismo 429 llega como `{error:{code,message}}`. Si escribes un manejador común para toda la API, contempla las dos.\n\nUn `403` por permisos **no** te gasta presupuesto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyRateLimitError"},"example":{"error":"Too many requests. Please try again later.","retryAfter":60}}}},"InternalErrorLegacy":{"description":"Error interno. **Sobre antiguo**: `error` es una cadena.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyError"},"example":{"error":"Internal server error"}}}},"InsufficientWriteScope":{"description":"La API key no lleva el permiso de escritura que exige esta operación.\n\n**A diferencia de la lectura, aquí el permiso se exige siempre.** Una clave antigua, creada sin permisos acotados, NO puede escribir: pide una clave nueva con el scope concreto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"insufficient_scope","message":"Esta API key no tiene el permiso \"contacts:write\"."}}}}},"InternalError":{"description":"Error interno. Reintentable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"internal_error","message":"Error interno"}}}}},"NotFound":{"description":"El recurso no existe, o pertenece a otro tenant, o la escritura pública está desactivada. Los tres casos comparten respuesta a propósito: distinguirlos filtraría la existencia de recursos ajenos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Contacto no encontrado"}}}}},"WriteDisabled":{"description":"La superficie de escritura pública no está activada en este entorno (`PUBLIC_API_WRITE_ENABLED`). Escribe a soporte para habilitarla en tu cuenta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"Not found"}}}}},"Conflict":{"description":"Conflicto con un recurso existente (p. ej. teléfono duplicado en el tenant).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"duplicate_contact","message":"Ya existe un contacto con el teléfono +34600111222"}}}}}},"schemas":{"ForbiddenCause":{"oneOf":[{"$ref":"#/components/schemas/LegacyError"},{"$ref":"#/components/schemas/Error"}],"description":"`LegacyError` (con `error` como cadena `insufficient_scope`/`INSUFFICIENT_SCOPE`) cuando falta el scope de la key. `Error` (con `error.code = \"capability_required\"`) cuando lo que falta es la función en el plan del tenant."},"LegacyError":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Slug del error, como cadena. Mayúsculas (`UNAUTHORIZED`) o minúsculas (`invalid_date`) según la ruta; no está unificado."},"message":{"type":"string","description":"Detalle legible. No todas las rutas lo incluyen."}}},"LegacyRateLimitError":{"type":"object","required":["error"],"properties":{"error":{"type":"string","example":"Too many requests. Please try again later."},"retryAfter":{"type":"integer","description":"Segundos hasta que se libera la cuota. Mismo valor que la cabecera `Retry-After`."}}},"CalendarDayHours":{"type":"object","required":["opens_at","closes_at"],"properties":{"opens_at":{"type":"string","example":"09:00"},"closes_at":{"type":"string","example":"18:00"},"break_starts_at":{"type":["string","null"],"example":"14:00"},"break_ends_at":{"type":["string","null"],"example":"15:00"}}},"CalendarOverride":{"type":"object","required":["date","override_type"],"properties":{"id":{"type":"string","format":"uuid"},"date":{"type":"string","format":"date"},"override_type":{"type":"string","enum":["closed","open","custom_hours"]},"opens_at":{"type":["string","null"]},"closes_at":{"type":["string","null"]},"break_starts_at":{"type":["string","null"]},"break_ends_at":{"type":["string","null"]},"reason":{"type":["string","null"],"description":"Motivo libre que escribió quien la creó."}}},"CalendarHoliday":{"type":"object","required":["date","name_local","scope","country_code"],"properties":{"date":{"type":"string","format":"date"},"name_local":{"type":"string","example":"Santiago Apóstol"},"name_en":{"type":["string","null"]},"scope":{"type":"string","enum":["national","regional","local","observance"]},"subdivision_code":{"type":["string","null"],"example":"ES-GA"},"country_code":{"type":"string","example":"ES"}}},"CalendarDayResolution":{"type":"object","required":["date","is_working_day","reason","schedule","applied_override","holiday"],"properties":{"date":{"type":"string","format":"date"},"is_working_day":{"type":"boolean","description":"Si ese DÍA es laborable. No mira la hora."},"reason":{"type":"string","enum":["not_configured","manual_override_closed","manual_override_open","manual_override_custom_hours","weekend","national_holiday","regional_holiday","local_holiday","regular_schedule"],"description":"Motivo estable de la decisión. Es el campo sobre el que ramificar en un agente."},"schedule":{"oneOf":[{"$ref":"#/components/schemas/CalendarDayHours"},{"type":"null"}],"description":"Horario del día. `null` si no es laborable."},"applied_override":{"oneOf":[{"$ref":"#/components/schemas/CalendarOverride"},{"type":"null"}],"description":"La anulación manual que decidió el resultado, si hubo alguna."},"holiday":{"oneOf":[{"$ref":"#/components/schemas/CalendarHoliday"},{"type":"null"}],"description":"El festivo que cayó ese día, si lo hubo."}}},"CalendarCheckResponse":{"allOf":[{"$ref":"#/components/schemas/CalendarDayResolution"},{"type":"object","required":["company_id","timezone","is_holiday"],"properties":{"company_id":{"type":"string","format":"uuid","description":"El tenant resuelto. Siempre el de la credencial."},"timezone":{"type":"string","example":"Europe/Madrid","description":"Zona horaria del tenant."},"is_holiday":{"type":"boolean"},"time":{"type":"string","example":"10:30","description":"Solo presente si lo mandaste en la petición."},"is_open_at_time":{"type":"boolean","description":"Solo presente si mandaste `time`. **Es distinto de `is_working_day`**: un día laborable a las 22:00 da `is_working_day: true`, `is_open_at_time: false`."}}}]},"CalendarWeeklyScheduleDay":{"type":"object","required":["day_of_week","is_working_day"],"properties":{"day_of_week":{"type":"integer","minimum":0,"maximum":6,"description":"0 = lunes … 6 = domingo."},"is_working_day":{"type":"boolean"},"opens_at":{"type":["string","null"]},"closes_at":{"type":["string","null"]},"break_starts_at":{"type":["string","null"]},"break_ends_at":{"type":["string","null"]}}},"CalendarRangeResponse":{"type":"object","required":["company_id","timezone","from","to","weekly_schedule","days","snapshot"],"properties":{"company_id":{"type":"string","format":"uuid"},"timezone":{"type":"string","example":"Europe/Madrid"},"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"},"weekly_schedule":{"type":"array","items":{"$ref":"#/components/schemas/CalendarWeeklyScheduleDay"},"description":"Horario semanal base. **Vacío** si el calendario está desactivado."},"days":{"type":"array","items":{"$ref":"#/components/schemas/CalendarDayResolution"},"description":"Un elemento por día del rango. **Vacío** si el calendario está desactivado."},"snapshot":{"type":"object","required":["holiday_source","holiday_source_version"],"description":"Procedencia del cuadro de festivos aplicado, para poder auditar una decisión.","properties":{"holiday_source":{"type":"string","example":"nager","description":"`none` si no hay país configurado."},"holiday_source_version":{"type":["string","null"]}}}}},"CallWebhookPayload":{"type":"object","required":["event","timestamp","data"],"description":"Cuerpo de `call.ended` y `call.failed`. Ambos comparten forma.","properties":{"event":{"type":"string","enum":["call.ended","call.failed"]},"timestamp":{"type":"string","format":"date-time","description":"Cuándo se emitió el evento."},"data":{"type":"object","required":["cdr_id","direction","destination_number","duration","billsec","hangup_cause","rated"],"properties":{"cdr_id":{"type":"string","format":"uuid","description":"Id del registro de la llamada."},"fs_uuid":{"type":["string","null"],"description":"Id de la llamada en la centralita."},"direction":{"type":"string","enum":["inbound","outbound"]},"caller_id_number":{"type":["string","null"],"description":"Origen en E.164."},"destination_number":{"type":"string","description":"Destino. `\"unknown\"` si no se pudo determinar."},"duration":{"type":"integer","description":"Duración real en segundos."},"billsec":{"type":"integer","description":"Segundos facturables. `0` en una llamada fallida."},"hangup_cause":{"type":"string","example":"NORMAL_CLEARING"},"rated":{"type":"boolean","description":"**Llega siempre en `false`**: la tarificación corre después. Este evento NO trae el coste. Para el importe consulta `GET /api/v1/calls` unos segundos más tarde."}}}}},"AgentCreatedWebhookPayload":{"type":"object","required":["event","timestamp","data"],"properties":{"event":{"type":"string","enum":["agent.created"]},"timestamp":{"type":"string","format":"date-time"},"data":{"type":"object","required":["agent_id","name","industry","ai_provider","status"],"properties":{"agent_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"industry":{"type":"string","description":"`\"custom\"` si se creó desde el constructor conversacional."},"ai_provider":{"type":"string","example":"vapi"},"status":{"type":"string","enum":["draft","active","paused","deleted","restricted"]},"source":{"type":"string","enum":["chat_builder"],"description":"Solo presente si se creó desde el constructor conversacional."}}}}},"MemberSummary":{"type":"object","required":["id","public_id","email","role","role_label","status","presence","access","created_at"],"description":"Un miembro del equipo. **Contiene datos personales** (nombre y email).","properties":{"id":{"type":"string","format":"uuid","description":"Id interno."},"public_id":{"type":"string","example":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A","description":"Id público estable."},"external_id":{"type":["string","null"],"description":"El identificador que le pusiste tú, si le pusiste alguno."},"email":{"type":"string"},"full_name":{"type":["string","null"]},"role":{"type":"string","enum":["owner","manager","operator","billing","viewer"]},"role_label":{"type":"string","example":"Operador","description":"El rol en castellano, para mostrar."},"department":{"type":["string","null"],"description":"Etiqueta del rol personalizado, si tiene uno."},"custom_role":{"type":["object","null"],"description":"Rol personalizado del tenant, si tiene uno.","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"label":{"type":"string"},"color":{"type":"string"},"icon":{"type":"string"}}},"status":{"type":"string","enum":["active","invited","suspended"]},"presence":{"type":"object","required":["online_now","last_seen_at","last_login_at"],"properties":{"online_now":{"type":"boolean","description":"Si se le ha visto hace muy poco."},"last_seen_at":{"type":["string","null"],"format":"date-time"},"last_login_at":{"type":["string","null"],"format":"date-time"}}},"access":{"type":"object","required":["can_view_all_calls","allowed_queues","allowed_phone_number_ids"],"description":"Hasta dónde llega esta persona dentro del tenant.","properties":{"can_view_all_calls":{"type":"boolean"},"allowed_queues":{"type":["array","null"],"items":{"type":"string"},"description":"`null` = sin restricción de colas."},"allowed_phone_number_ids":{"type":["array","null"],"items":{"type":"string"},"description":"`null` = sin restricción de números."}}},"created_at":{"type":"string","format":"date-time"},"similarity_score":{"type":"number","description":"**Solo presente cuando `meta.fuzzy_match` es `true`.** Cuánto se parece al texto buscado, de 0 a 1. Confírmalo con la persona antes de actuar sobre él."}}},"MembersResponse":{"type":"object","required":["members","meta"],"properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberSummary"}},"meta":{"type":"object","required":["total","page","per_page","total_pages","online_count","active_count","fuzzy_match"],"properties":{"total":{"type":"integer","description":"Total de miembros que cumplen el filtro."},"page":{"type":"integer"},"per_page":{"type":"integer"},"total_pages":{"type":"integer"},"online_count":{"type":"integer","description":"Cuántos están conectados ahora, en todo el tenant."},"active_count":{"type":"integer","description":"Cuántos están activos, en todo el tenant."},"fuzzy_match":{"type":"boolean","description":"`true` si estos resultados vienen de la búsqueda por parecido porque la exacta no encontró nada. Cuando es `true`, `total` y `total_pages` son los de la lista aproximada (máximo 5), no los del filtro."}}}}},"SendEmailRequest":{"type":"object","required":["to","subject"],"description":"Hace falta `subject` y al menos uno de `html` o `text`.","properties":{"to":{"type":"string","example":"cliente@ejemplo.com","description":"Destinatario. Uno solo."},"subject":{"type":"string"},"html":{"type":"string","description":"Cuerpo en HTML."},"text":{"type":"string","description":"Cuerpo en texto plano."},"cc":{"type":"array","items":{"type":"string"},"description":"Copias. **Las direcciones que no sean válidas se descartan en silencio.**"},"bcc":{"type":"array","items":{"type":"string"},"description":"Copias ocultas. Mismo descarte silencioso que `cc`."},"reply_to":{"type":"string","description":"Dirección a la que responder."},"from":{"type":"string","description":"Remitente concreto. **Debe ser un buzón dado de alta en tu tenant**; si no, responde `403 FROM_ADDRESS_NOT_REGISTERED`. Nunca se suplanta una dirección ajena. Si lo omites se usa el buzón por defecto del tenant."},"contact_id":{"type":["string","null"],"description":"Contacto al que asociar el envío, para que aparezca en su ficha."},"call_id":{"type":["string","null"],"description":"Llamada a la que asociar el envío."},"allow_amai_fallback":{"type":"boolean","default":true,"description":"**Decide si el correo puede salir por la infraestructura de AMAI.** Por defecto `true`.\n\nCuando se usa ese camino, el correo sale como **`AMAI Voice <info@amai.solutions>`**, no con tu remitente. Si vendes bajo tu propia marca, eso enseña la nuestra a tu cliente: manda `false` para que falle en vez de salir con la marca equivocada, y configura el proveedor de correo de tu tenant.\n\nLa respuesta dice en `used_fallback` qué camino se acabó usando."}}},"SendEmailResponse":{"type":"object","required":["success","message_id","used_fallback"],"properties":{"success":{"type":"boolean","enum":[true]},"message_id":{"type":["string","null"],"description":"Nuestro identificador del envío."},"provider_message_id":{"type":["string","null"],"description":"El identificador que devolvió el proveedor de correo."},"used_fallback":{"type":"boolean","description":"`true` si salió por la infraestructura de AMAI (remitente `AMAI Voice <info@amai.solutions>`) en lugar de por el proveedor de tu tenant. **Revísalo si trabajas con marca propia.**"}}},"LinkCallMembersRequest":{"type":"object","required":["member_ids"],"properties":{"member_ids":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"string"},"description":"De 1 a 5 personas. Cada elemento acepta el id público (`mem_<ULID>`), el UUID interno, o `extref:<tu_id>` si les pusiste un identificador propio."},"reason":{"type":"string","description":"Texto libre. Se guarda en la auditoría."},"source":{"type":"string","example":"api","description":"De dónde viene la asignación (`ui`, `api`, `agent_tool`, `webhook`, `auto`)."}}},"LinkedCallMember":{"type":"object","required":["id","full_name","email"],"properties":{"id":{"type":"string","example":"mem_01J9Z8QK2M4N6P8R0T2V4X6Z8A"},"full_name":{"type":["string","null"]},"email":{"type":["string","null"]}}},"LinkCallMembersResponse":{"type":"object","required":["call_id","members","member","previous_member_id","linked_at","linked_by"],"properties":{"call_id":{"type":"string","description":"Id público de la llamada."},"members":{"type":"array","items":{"$ref":"#/components/schemas/LinkedCallMember"},"description":"Todas las personas vinculadas por esta petición."},"member":{"oneOf":[{"$ref":"#/components/schemas/LinkedCallMember"},{"type":"null"}],"description":"La primera de `members`. Se mantiene por compatibilidad con integraciones de una sola persona; en código nuevo usa `members`."},"previous_member_id":{"type":["string","null"],"description":"Quién la tenía asignada antes, si había alguien."},"linked_at":{"type":"string","format":"date-time"},"linked_by":{"type":"object","required":["type","id","label"],"description":"Quién hizo la asignación. Con una API key, `type` es `api_token`.","properties":{"type":{"type":"string","enum":["user","api_token"]},"id":{"type":["string","null"]},"label":{"type":["string","null"]}}}}},"UnlinkCallMemberResponse":{"type":"object","required":["call_id","unlinked_member_id","linked_at"],"properties":{"call_id":{"type":"string"},"unlinked_member_id":{"type":["string","null"],"description":"Quién estaba asignado y ha dejado de estarlo. `null` si no había nadie."},"linked_at":{"type":["string","null"],"format":"date-time"}}},"MentionCandidate":{"type":"object","required":["user_id","display_name","email","role","avatar_initials"],"description":"Una persona mencionable. **Contiene datos personales** (nombre y email): trátalo como tal.","properties":{"user_id":{"type":"string","format":"uuid"},"display_name":{"type":"string","example":"Marta Gómez","description":"Nombre a mostrar. Si la persona no tiene nombre puesto, cae a la parte local del email."},"email":{"type":"string","example":"marta@ejemplo.com","description":"Email real de la persona. **Cadena vacía** si el directorio de autenticación no lo resolvió."},"role":{"type":["string","null"],"example":"owner","description":"`owner` para el propietario."},"avatar_initials":{"type":"string","example":"MG"}}},"MentionListResponse":{"type":"object","required":["members"],"properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/MentionCandidate"}}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"Slug estable del error."},"message":{"type":"string"},"meta_code":{"type":["integer","null"],"description":"Código de Meta cuando aplica."}},"required":["code","message"]}}},"CallCost":{"type":"object","description":"Coste de la llamada **para ti**, en USD con 4 decimales. Son importes numéricos, no cadenas ni céntimos.","required":["currency","rate_per_minute","telephony_usd","ai_usd","total_usd"],"properties":{"currency":{"type":"string","enum":["USD"],"description":"Siempre USD."},"rate_per_minute":{"type":["number","null"],"example":0.012,"description":"Tarifa por minuto aplicada al destino de esta llamada. Puede ser `null` en una llamada sin segundos facturados, donde no hay tarifa que aplicar."},"telephony_usd":{"type":"number","example":0.0132,"description":"Parte de telefonía."},"ai_usd":{"type":"number","example":0.045,"description":"Parte de IA (agente de voz). 0 en llamadas sin agente."},"total_usd":{"type":"number","example":0.0582,"description":"`telephony_usd + ai_usd`. Es la columna «Coste (USD)» del CSV."}}},"Call":{"type":"object","description":"Una llamada del tenant. Las dos piernas de una llamada de agente IA ya vienen plegadas: una llamada aparece **una sola vez**, con su coste una sola vez.","required":["id","started_at","ended_at","direction","from","to","duration_sec","billed_sec","status","cost"],"properties":{"id":{"type":"string","example":"call_a1b2c3d4","description":"Identificador público y estable de la llamada."},"started_at":{"type":"string","format":"date-time","example":"2026-07-23T14:32:11Z","description":"Inicio, ISO-8601 UTC. Es la columna «Fecha» del CSV, en formato de máquina."},"ended_at":{"type":["string","null"],"format":"date-time","description":"Fin, ISO-8601 UTC. `null` si la llamada no llegó a terminar con marca de fin."},"direction":{"type":"string","enum":["inbound","outbound"]},"from":{"type":"string","example":"+573501234567","description":"Origen en E.164."},"to":{"type":"string","example":"+34600111222","description":"Destino en E.164."},"destination_label":{"type":["string","null"],"example":"Spain Mobile","description":"Etiqueta del destino tarifado (país y tipo de línea)."},"duration_sec":{"type":"integer","example":66,"description":"Duración real en segundos."},"billed_sec":{"type":"integer","example":66,"description":"Segundos facturados (incremento de facturación aplicado). Es lo que se cobra."},"status":{"type":"string","enum":["completed","failed","no_answer","busy"],"description":"Resultado normalizado y estable de la API."},"hangup_cause":{"type":["string","null"],"example":"NORMAL_CLEARING","description":"Causa de finalización en crudo, para quien quiera el detalle de la centralita."},"sip_account":{"type":["string","null"],"example":"1001","description":"Cuenta SIP implicada."},"call_origin":{"type":["string","null"],"example":"ai_agent","description":"Origen de la llamada (por ejemplo, agente IA o troncal SIP)."},"vapi_call_id":{"type":["string","null"],"example":"019fd639-899a-755d-b1f8-dadab73ded9c","description":"Id de la llamada en VAPI, o `null` si la llamada no pasó por VAPI. `PUT /v1/calls/{call_id}/member` lo acepta como identificador, así que se publica aquí: es el único id que una herramienta del agente conoce a mitad de llamada."},"cost":{"$ref":"#/components/schemas/CallCost"}}},"CallsPagination":{"type":"object","required":["limit","offset","total","has_more"],"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":412,"description":"Total de llamadas del rango filtrado, no de la página."},"has_more":{"type":"boolean","description":"`true` mientras queden páginas. Es la condición de parada del bucle de paginación."}}},"CallsSummary":{"type":"object","description":"Agregados de **todo el rango filtrado**, calculados en base de datos. No dependen de `limit` ni de `offset`: son idénticos en todas las páginas.","required":["period","total_calls","connected_calls","failed_calls","total_billed_seconds","total_billed_minutes","telephony_cost_usd","ai_cost_usd","total_cost_usd","average_cost_per_minute_usd"],"properties":{"period":{"type":"object","description":"Rango efectivamente aplicado, ya resuelto con los valores por defecto.","required":["start","end"],"properties":{"start":{"type":"string","format":"date-time","example":"2026-07-01T00:00:00Z"},"end":{"type":"string","format":"date-time","example":"2026-07-31T23:59:59Z"}}},"total_calls":{"type":"integer","example":412},"connected_calls":{"type":"integer","example":388},"failed_calls":{"type":"integer","example":24},"total_billed_seconds":{"type":"integer","example":24870},"total_billed_minutes":{"type":"number","example":414.5},"telephony_cost_usd":{"type":"number","example":8.1},"ai_cost_usd":{"type":"number","example":4.3402},"total_cost_usd":{"type":"number","example":12.4402},"average_cost_per_minute_usd":{"type":["number","null"],"example":0.03,"description":"`total_cost_usd / total_billed_minutes`. **`null`** cuando el periodo no tiene minutos facturados — nunca `0`, que se leería como «sale gratis»."}}},"CallsResponse":{"type":"object","required":["data","pagination","summary"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Call"}},"pagination":{"$ref":"#/components/schemas/CallsPagination"},"summary":{"$ref":"#/components/schemas/CallsSummary"}}},"SendMessageRequest":{"type":"object","required":["to"],"properties":{"to":{"type":"string","description":"Destinatario en E.164.","example":"+34600111222"},"type":{"type":"string","enum":["text","template"],"default":"text"},"text":{"type":"string","description":"Cuerpo (si type=text)."},"template_name":{"type":"string","description":"Plantilla aprobada (si type=template)."},"template_language":{"type":"string","default":"es"},"template_components":{"type":"array","description":"Componentes de Meta para variables de plantilla.","items":{"type":"object"}},"connection_id":{"type":"string","description":"Conexión concreta (opcional)."}}},"SendMessageResponse":{"type":"object","properties":{"success":{"type":"boolean"},"message_id":{"type":["string","null"]}}},"WhatsAppMessage":{"type":"object","description":"Un mensaje de WhatsApp del tenant. La consulta devuelve **la fila entera**, así que estos son todos los campos que vas a recibir — incluidos los de facturación y los de error, que antes llegaban sin estar declarados.","properties":{"id":{"type":"string","format":"uuid"},"connection_id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"wamid":{"type":["string","null"],"description":"Id del mensaje en WhatsApp. Único; sirve para no procesar dos veces el mismo."},"direction":{"type":"string","enum":["inbound","outbound"]},"from_number":{"type":"string","description":"Remitente en E.164."},"to_number":{"type":"string","description":"Destinatario en E.164."},"contact_name":{"type":["string","null"],"description":"Nombre del perfil de WhatsApp, si lo publica."},"message_type":{"type":"string","enum":["text","template","image","audio","video","document","interactive","reaction","location","contacts","sticker"]},"body":{"type":["string","null"],"description":"Texto del mensaje, o pie de la imagen."},"media_url":{"type":["string","null"],"description":"URL de descarga del adjunto. **Caduca**: descárgalo, no lo guardes como enlace."},"media_mime_type":{"type":["string","null"]},"template_name":{"type":["string","null"],"description":"Solo en salientes por plantilla."},"template_language":{"type":["string","null"]},"status":{"type":["string","null"],"enum":["pending","sent","delivered","read","failed",null],"description":"Solo tiene sentido en salientes."},"error_code":{"type":["string","null"],"description":"Código de error de WhatsApp si el envío falló."},"error_message":{"type":["string","null"],"description":"Descripción legible del fallo."},"conversation_id":{"type":["string","null"],"description":"Id de conversación de Meta. Agrupa los mensajes que Meta factura juntos."},"conversation_category":{"type":["string","null"],"enum":["utility","authentication","marketing","service",null],"description":"**Tiene implicación económica**: la categoría con la que Meta tarifa la conversación."},"billable":{"type":["boolean","null"],"description":"**Tiene implicación económica**: si este mensaje abrió una conversación facturable. Los mensajes dentro de una conversación ya abierta van a `false`."},"timestamp":{"type":"string","format":"date-time","description":"Momento del mensaje. Es el campo por el que se ordena, de más nuevo a más viejo."},"status_updated_at":{"type":["string","null"],"format":"date-time","description":"Última vez que cambió `status` (entregado, leído…)."},"created_at":{"type":["string","null"],"format":"date-time","description":"Cuándo lo guardamos nosotros. Puede diferir de `timestamp`."}}},"InboundMessage":{"type":"object","properties":{"connection_id":{"type":"string"},"message_id":{"type":"string"},"wamid":{"type":"string"},"direction":{"type":"string","example":"inbound"},"from_number":{"type":"string"},"to_number":{"type":"string"},"contact_name":{"type":["string","null"]},"message_type":{"type":"string"},"body":{"type":["string","null"]},"media_mime_type":{"type":["string","null"]},"timestamp":{"type":"string","format":"date-time"}}},"Template":{"type":"object","properties":{"name":{"type":"string"},"status":{"type":"string","example":"APPROVED"},"category":{"type":"string","example":"UTILITY"},"language":{"type":"string","example":"es"},"variables":{"type":"integer","description":"Número de variables {{n}} del cuerpo."},"body_text":{"type":["string","null"]}}},"ContactType":{"type":"string","enum":["lead","prospect","client","partner"]},"ContactStatus":{"type":"string","enum":["new","contacted","interested","qualified","callback","no_answer","voicemail","not_interested","converted","disqualified","do_not_call","invalid","discovery","interest_detected","qualification_in_progress","appointment_proposed","appointment_booked","follow_up","proposal","negotiation","won","lost"]},"CreateContactRequest":{"type":"object","required":["phone_e164","first_name"],"description":"`distributor_id` NO es un campo aceptado: el tenant se deriva de la API key. Si lo envías, se ignora.","properties":{"phone_e164":{"type":"string","description":"Teléfono en cualquier forma marcable; se normaliza a E.164.","example":"+34600111222"},"first_name":{"type":"string","example":"Ana"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"email":{"type":["string","null"]},"position":{"type":["string","null"]},"notes":{"type":["string","null"]},"external_id":{"type":["string","null"],"description":"Tu identificador en el sistema de origen, para conciliar."},"type":{"$ref":"#/components/schemas/ContactType"},"status":{"$ref":"#/components/schemas/ContactStatus"},"tags":{"type":"array","items":{"type":"string"}},"metadata":{"type":"object","additionalProperties":true}}},"UpdateContactRequest":{"type":"object","description":"Todos los campos son opcionales; hay que enviar al menos uno.","properties":{"first_name":{"type":"string"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"email":{"type":["string","null"]},"position":{"type":["string","null"]},"notes":{"type":["string","null"]},"phone_e164":{"type":"string"},"type":{"$ref":"#/components/schemas/ContactType"},"status":{"$ref":"#/components/schemas/ContactStatus"},"tags":{"type":"array","items":{"type":"string"},"description":"Reemplaza el conjunto actual."},"opt_out":{"type":"boolean"},"do_not_call":{"type":"boolean"},"metadata":{"type":"object","additionalProperties":true}}},"Contact":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"first_name":{"type":"string"},"last_name":{"type":["string","null"]},"company":{"type":["string","null"]},"phone_e164":{"type":"string"},"phone_display":{"type":["string","null"]},"email":{"type":["string","null"]},"type":{"$ref":"#/components/schemas/ContactType"},"status":{"$ref":"#/components/schemas/ContactStatus"},"tags":{"type":"array","items":{"type":"string"}},"source":{"type":"string","example":"api"},"external_id":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"}}},"ContactList":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"source":{"type":"string","example":"api"},"contact_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}}},"UpdateAgentRequest":{"type":"object","description":"Todos los campos son opcionales; hay que enviar al menos uno. `status` no se acepta.","properties":{"name":{"type":"string","maxLength":200},"description":{"type":["string","null"]},"first_message":{"type":["string","null"],"maxLength":2000,"description":"Primera frase que dice el agente al descolgar."},"system_prompt":{"type":["string","null"],"maxLength":40000},"language":{"type":"string","example":"es","description":"Código tipo \"es\" o \"es-ES\"."},"voice_id":{"type":"string","description":"Id de voz del proveedor (ElevenLabs / OpenAI)."}}},"Agent":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"distributor_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"language":{"type":"string"},"voice_id":{"type":["string","null"]},"first_message":{"type":["string","null"]},"system_prompt":{"type":["string","null"]},"status":{"type":"string","enum":["draft","active","paused","deleted","restricted"]},"vapi_assistant_id":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}}}}}