{
  "openapi": "3.1.0",
  "info": {
    "title": "SafeOnuba API",
    "version": "v1 (2026-09-24)",
    "description": "Alertas y evacuaciones de SafeOnuba para el sistema de la sala de control del cliente.\n\nTres reglas: la API **ve lo mismo que un usuario del panel** con esos permisos (el velo de\n«Solo en emergencia», las zonas de privacidad y el alcance por contrata se aplican igual);\n**no hay constantes vitales**, ni agregadas; y en v1 **no abre evacuaciones**: el sistema\npropone, la persona decide.\n\nTipos de evento: `alert.created`, `alert.acknowledged`, `alert.resolved`, `alert.reopened`, `alert.worker_acknowledged`, `muster.started`, `muster.kind_changed`, `muster.phase_changed`, `muster.targets_changed`, `muster.updated`, `muster.worker_missing`, `muster.ended`."
  },
  "servers": [
    {
      "url": "https://api.safeonuba.com",
      "description": "Producción"
    },
    {
      "url": "https://sandbox.api.safeonuba.com",
      "description": "Pruebas, con relojes simulados"
    }
  ],
  "tags": [
    {
      "name": "Autenticación",
      "description": "OAuth2 `client_credentials`: un token opaco de una hora."
    },
    {
      "name": "Centros",
      "description": "Los centros del cliente y sus zonas."
    },
    {
      "name": "Alertas",
      "description": "Lo que ha pasado, y reconocerlo o cerrarlo desde la sala del cliente."
    },
    {
      "name": "Evacuaciones",
      "description": "Estado, recuento por estado y por punto, descartes y quién falta."
    },
    {
      "name": "Trabajadores",
      "description": "Trabajadores y relojes."
    },
    {
      "name": "Posiciones",
      "description": "Solo las que el velo deja ver."
    },
    {
      "name": "Eventos",
      "description": "Reproducir los eventos de los últimos 30 días."
    },
    {
      "name": "Webhooks",
      "description": "Destinos y el formato de lo que se les envía."
    },
    {
      "name": "Sandbox",
      "description": "Escenarios de prueba con relojes simulados, para integrar sin relojes. Solo en `sandbox.api.safeonuba.com`."
    }
  ],
  "components": {
    "securitySchemes": {
      "oauth2": {
        "type": "oauth2",
        "description": "Un cliente por empresa; el token dice qué scopes tiene. Cada petición queda en el registro de auditoría.",
        "flows": {
          "clientCredentials": {
            "tokenUrl": "/v1/oauth/token",
            "scopes": {
              "alerts:read": "Leer alertas y zonas.",
              "alerts:write": "Reconocer y cerrar alertas (siempre con el operador).",
              "positions:read": "Posiciones que el velo deja ver.",
              "musters:read": "Evacuaciones y recuento.",
              "workers:read": "Trabajadores y relojes.",
              "webhooks:manage": "Destinos de webhook."
            }
          }
        }
      }
    },
    "schemas": {
      "Event": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
            "description": "Para deduplicar: la entrega es «al menos una vez»."
          },
          "type": {
            "$ref": "#/components/schemas/EventType"
          },
          "api_version": {
            "type": "string",
            "enum": [
              "2026-09-24"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
            "description": "La empresa del centro. `null` en los eventos de prueba."
          },
          "site_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "data": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "alert": {
                    "$ref": "#/components/schemas/Alert"
                  }
                },
                "required": [
                  "alert"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "muster": {
                    "$ref": "#/components/schemas/Muster"
                  }
                },
                "required": [
                  "muster"
                ]
              }
            ],
            "description": "El objeto completo tal como lo devolvería el `GET` en ese instante."
          }
        },
        "required": [
          "id",
          "type",
          "api_version",
          "created_at",
          "organization_id",
          "site_id",
          "data"
        ]
      },
      "EventType": {
        "type": "string",
        "enum": [
          "alert.created",
          "alert.acknowledged",
          "alert.resolved",
          "alert.reopened",
          "alert.worker_acknowledged",
          "muster.started",
          "muster.kind_changed",
          "muster.phase_changed",
          "muster.targets_changed",
          "muster.updated",
          "muster.worker_missing",
          "muster.ended"
        ],
        "description": "`muster.updated`: el recuento ha cambiado (se comprueba cada 5 s, solo se emite si cambia). `muster.targets_changed`: la sala ha descartado o rehabilitado un punto de reunión."
      },
      "Alert": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "type": {
            "$ref": "#/components/schemas/AlertType"
          },
          "severity": {
            "$ref": "#/components/schemas/Severity"
          },
          "status": {
            "$ref": "#/components/schemas/AlertStatus"
          },
          "title": {
            "type": "string",
            "description": "Con `include_worker_names`, el título que lee la sala (lleva el nombre de la persona). Sin él, uno compuesto con el tipo, la zona y el `external_id`, sin nombre.",
            "example": "SOS activado — Ana Ejemplo"
          },
          "date_created": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "date_last_modified": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z",
            "description": "Para ordenar: los webhooks no garantizan el orden, y lo más viejo que lo que ya se tiene se descarta."
          },
          "site": {
            "$ref": "#/components/schemas/SiteRef"
          },
          "zone": {
            "$ref": "#/components/schemas/ZoneRef"
          },
          "worker": {
            "$ref": "#/components/schemas/WorkerRef"
          },
          "device": {
            "$ref": "#/components/schemas/DeviceRef"
          },
          "location": {
            "$ref": "#/components/schemas/Location"
          },
          "location_withheld": {
            "type": "boolean",
            "description": "La posición existe pero no se entrega: en «Solo en emergencia», una alerta por debajo de la gravedad que destapa no la enseña, tampoco en el panel.",
            "example": false
          },
          "details": {
            "$ref": "#/components/schemas/AlertDetails"
          },
          "nearby": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Nearby"
            }
          },
          "vehicle_warnings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VehicleWarning"
            }
          },
          "worker_acknowledged_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z",
            "description": "El trabajador pulsó «Entendido» en su reloj (hora del reloj)."
          },
          "acknowledged": {
            "$ref": "#/components/schemas/ActionBy"
          },
          "resolved": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ActionBy"
              },
              {
                "type": [
                  "object",
                  "null"
                ],
                "properties": {
                  "note": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "example": "Falsa alarma: pulsado sin querer."
                  }
                },
                "required": [
                  "note"
                ]
              }
            ],
            "description": "Cerrada: quién, cuándo y con qué nota."
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ],
            "example": "Berta Ejemplo"
          },
          "simulated": {
            "type": "boolean",
            "description": "Generada en un simulacro o con relojes simulados.",
            "example": false
          },
          "panel_url": {
            "type": "string",
            "format": "uri",
            "example": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          }
        },
        "required": [
          "id",
          "type",
          "severity",
          "status",
          "title",
          "date_created",
          "date_last_modified",
          "site",
          "zone",
          "worker",
          "device",
          "location",
          "location_withheld",
          "details",
          "nearby",
          "vehicle_warnings",
          "worker_acknowledged_at",
          "acknowledged",
          "resolved",
          "assigned_to",
          "simulated",
          "panel_url"
        ],
        "example": {
          "id": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
          "type": "sos",
          "severity": "critical",
          "status": "unacknowledged",
          "title": "SOS activado — Ana Ejemplo",
          "date_created": "2026-09-24T11:04:12Z",
          "date_last_modified": "2026-09-24T11:04:12Z",
          "site": {
            "id": "11111111-1111-1111-1111-111111111111",
            "name": "Planta Ejemplo — simulación"
          },
          "zone": null,
          "worker": {
            "id": "33333333-3333-3333-3333-333333333305",
            "external_id": "EMP-004512",
            "name": "Ana Ejemplo",
            "contractor": "Contrata Ejemplo A"
          },
          "device": {
            "id": "44444444-4444-4444-4444-444444444405",
            "serial": "OS-W-0005",
            "battery": 64,
            "date_last_seen": "2026-09-24T11:04:10Z"
          },
          "location": {
            "latitude": 40.00121,
            "longitude": -3.00214,
            "uncertainty_m": 8,
            "source": "gps",
            "masked": false,
            "position_date_utc": "2026-09-24T11:04:08Z",
            "height": null
          },
          "location_withheld": false,
          "details": {
            "trigger": "watch_button"
          },
          "nearby": [
            {
              "kind": "vehicle",
              "worker": {
                "id": "33333333-3333-3333-3333-333333333309",
                "external_id": "EMP-002210",
                "name": "Carlos Ejemplo",
                "contractor": "Contrata Ejemplo B"
              },
              "vehicle_type": "Grúa móvil",
              "distance_m": 18,
              "reported_at": "2026-09-24T11:04:02Z"
            },
            {
              "kind": "person",
              "worker": {
                "id": "33333333-3333-3333-3333-333333333301",
                "external_id": "EMP-000981",
                "name": "Diego Ejemplo",
                "contractor": null
              },
              "vehicle_type": null,
              "distance_m": 26,
              "reported_at": "2026-09-24T11:03:58Z"
            }
          ],
          "vehicle_warnings": [],
          "worker_acknowledged_at": null,
          "acknowledged": null,
          "resolved": null,
          "assigned_to": null,
          "simulated": false,
          "panel_url": "https://app.safeonuba.com/alertas/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
        }
      },
      "AlertType": {
        "type": "string",
        "enum": [
          "sos",
          "fall_detected",
          "restricted_zone_entry",
          "forbidden_zone_entry",
          "permit_expired_inside",
          "permit_closed_inside",
          "heat_stress",
          "evacuation_help",
          "worker_call_request",
          "device_offline",
          "low_battery",
          "earthquake",
          "tsunami_risk"
        ],
        "description": "Qué ha pasado. `earthquake` y `tsunami_risk` son de la planta entera: no llevan `worker` ni `device`. Un vehículo cerca **no** es una alerta: sale dentro de la alerta de un incidente (`nearby`, `vehicle_warnings`)."
      },
      "Severity": {
        "type": "string",
        "enum": [
          "low",
          "medium",
          "high",
          "critical"
        ],
        "description": "Gravedad: `low` (información), `medium` (aviso), `high` (grave), `critical` (crítica: SOS, caída, «no puedo evacuar»)."
      },
      "AlertStatus": {
        "type": "string",
        "enum": [
          "unacknowledged",
          "acknowledged",
          "resolved"
        ],
        "description": "`acknowledged`: un operador se ha hecho cargo. No es lo mismo que `worker_acknowledged_at`, que es que el trabajador ha visto el aviso en su reloj."
      },
      "SiteRef": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "name": {
            "type": "string",
            "example": "Planta Ejemplo — simulación"
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "ZoneRef": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "name": {
            "type": "string",
            "example": "Almacenamiento H₂ (ATEX)"
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "WorkerRef": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código del trabajador en la empresa (el de su sistema de personal). `null` si no se ha dado.",
            "example": "EMP-004512"
          },
          "name": {
            "type": "string",
            "description": "Nombre y apellidos. **Solo** si el contrato lo incluye; si no, la propiedad no aparece.",
            "example": "Ana Ejemplo"
          },
          "contractor": {
            "type": [
              "string",
              "null"
            ],
            "example": "Contrata Ejemplo A"
          }
        },
        "required": [
          "id",
          "external_id",
          "contractor"
        ]
      },
      "DeviceRef": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "serial": {
            "type": "string",
            "example": "OS-W-0005"
          },
          "battery": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 100,
            "example": 64
          },
          "date_last_seen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          }
        },
        "required": [
          "id",
          "serial",
          "battery",
          "date_last_seen"
        ]
      },
      "Location": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "latitude": {
            "type": "number",
            "minimum": -90,
            "maximum": 90,
            "example": 40.00121
          },
          "longitude": {
            "type": "number",
            "minimum": -180,
            "maximum": 180,
            "example": -3.00214
          },
          "uncertainty_m": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "description": "Radio de incertidumbre en metros. `null` si no se conoce: no saberla no es tenerla perfecta.",
            "example": 8
          },
          "source": {
            "type": "string",
            "enum": [
              "gps",
              "network",
              "zone"
            ],
            "description": "`zone`: la persona estaba en una zona de privacidad y el punto es el de la zona, no el suyo.",
            "example": "gps"
          },
          "masked": {
            "type": "boolean",
            "description": "El punto es el representativo de una zona de privacidad.",
            "example": false
          },
          "position_date_utc": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "height": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "height_m": {
                "type": "number",
                "example": 7.6
              },
              "uncertainty_m": {
                "type": "number",
                "example": 1
              },
              "building": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "Nave de proceso"
              },
              "floor": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "Planta 1"
              }
            },
            "required": [
              "height_m",
              "uncertainty_m",
              "building",
              "floor"
            ],
            "description": "Altura sobre el suelo por barómetro, cuando se puede afirmar; `null` si no."
          }
        },
        "required": [
          "latitude",
          "longitude",
          "uncertainty_m",
          "source",
          "masked",
          "position_date_utc",
          "height"
        ]
      },
      "AlertDetails": {
        "anyOf": [
          {
            "$ref": "#/components/schemas/SosDetails"
          },
          {
            "$ref": "#/components/schemas/FallDetails"
          },
          {
            "$ref": "#/components/schemas/ZoneDetails"
          },
          {
            "$ref": "#/components/schemas/HeatStressDetails"
          },
          {
            "$ref": "#/components/schemas/EvacuationHelpDetails"
          },
          {
            "$ref": "#/components/schemas/WorkerCallDetails"
          },
          {
            "$ref": "#/components/schemas/DeviceOfflineDetails"
          },
          {
            "$ref": "#/components/schemas/LowBatteryDetails"
          },
          {
            "$ref": "#/components/schemas/HazardDetails"
          }
        ],
        "description": "Depende de `type`."
      },
      "SosDetails": {
        "type": "object",
        "properties": {
          "trigger": {
            "type": "string",
            "enum": [
              "watch_button",
              "watch_hardware_button",
              "after_fall",
              "control_room"
            ],
            "description": "Cómo se pidió: en la pantalla del reloj, con el botón físico, tras una caída sin respuesta o desde la sala.",
            "example": "watch_button"
          }
        },
        "required": [
          "trigger"
        ]
      },
      "FallDetails": {
        "type": "object",
        "properties": {
          "escalated_to_sos": {
            "type": "boolean",
            "description": "No contestó al «¿Estás bien?» del reloj y pasó a SOS.",
            "example": true
          }
        },
        "required": [
          "escalated_to_sos"
        ]
      },
      "ZoneDetails": {
        "type": "object",
        "properties": {
          "zone_type": {
            "type": "string",
            "enum": [
              "restricted",
              "forbidden"
            ],
            "example": "forbidden"
          },
          "permit_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "El permiso de trabajo (ATS) que regía, si lo había.",
            "example": "PT-0453"
          }
        },
        "required": [
          "zone_type",
          "permit_reference"
        ]
      },
      "HeatStressDetails": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "stop_and_rest"
            ],
            "description": "Lo que se le ha dicho en el reloj: parar, beber y buscar sombra. No hay niveles."
          },
          "heat_index_c": {
            "type": [
              "number",
              "null"
            ],
            "description": "Índice de calor de la planta (°C).",
            "example": 33
          },
          "sun_exposure_minutes": {
            "type": [
              "integer",
              "null"
            ],
            "example": 91
          }
        },
        "required": [
          "action",
          "heat_index_c",
          "sun_exposure_minutes"
        ],
        "description": "Sin nada que salga del pulso: las constantes vitales no salen por la API, ni agregadas."
      },
      "EvacuationHelpDetails": {
        "type": "object",
        "properties": {
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "trapped",
              "injured",
              "helping",
              null
            ],
            "description": "Lo que dijo en el reloj: atrapado, herido o ayudando a otra persona.",
            "example": "trapped"
          },
          "muster_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          }
        },
        "required": [
          "reason",
          "muster_id"
        ]
      },
      "WorkerCallDetails": {
        "type": "object",
        "properties": {
          "voice_note": {
            "type": "boolean",
            "description": "Sin cobertura, el reloj grabó un aviso de voz (se escucha en el panel).",
            "example": false
          }
        },
        "required": [
          "voice_note"
        ]
      },
      "DeviceOfflineDetails": {
        "type": "object",
        "properties": {
          "minutes_silent": {
            "type": "integer",
            "example": 17
          }
        },
        "required": [
          "minutes_silent"
        ]
      },
      "LowBatteryDetails": {
        "type": "object",
        "properties": {
          "battery_pct": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "example": 12
          }
        },
        "required": [
          "battery_pct"
        ]
      },
      "HazardDetails": {
        "type": "object",
        "properties": {
          "magnitude": {
            "type": [
              "number",
              "null"
            ],
            "example": 5.2
          },
          "distance_km": {
            "type": [
              "number",
              "null"
            ],
            "example": 180
          },
          "official": {
            "type": "boolean",
            "description": "`false`: estimación de SafeOnuba sin aviso oficial. `true`: boletín oficial confirmado por una persona.",
            "example": false
          },
          "felt_at_site": {
            "type": "boolean",
            "example": false
          },
          "eta": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z",
            "description": "Llegada estimada de la ola (tsunami)."
          }
        },
        "required": [
          "magnitude",
          "distance_km",
          "official",
          "felt_at_site",
          "eta"
        ]
      },
      "Nearby": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "person",
              "vehicle"
            ],
            "example": "vehicle"
          },
          "worker": {
            "allOf": [
              {
                "$ref": "#/components/schemas/WorkerRef"
              },
              {
                "type": "object",
                "properties": {
                  "id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
                    "description": "`null` en alertas anteriores al 24/09/2026, cuando aún no se copiaba."
                  }
                }
              }
            ]
          },
          "vehicle_type": {
            "type": [
              "string",
              "null"
            ],
            "example": "Grúa móvil"
          },
          "distance_m": {
            "type": "integer",
            "example": 18
          },
          "reported_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          }
        },
        "required": [
          "kind",
          "worker",
          "vehicle_type",
          "distance_m",
          "reported_at"
        ],
        "description": "Quién había a menos de 100 m cuando saltó una alerta crítica (SOS, caída). Distancia, nunca posición; quien estaba en una zona de privacidad no cuenta."
      },
      "VehicleWarning": {
        "type": "object",
        "properties": {
          "at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "vehicle_type": {
            "type": [
              "string",
              "null"
            ],
            "example": "Carretilla"
          },
          "distance_m": {
            "type": "integer",
            "example": 8
          },
          "worker_acknowledged_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          }
        },
        "required": [
          "at",
          "vehicle_type",
          "distance_m",
          "worker_acknowledged_at"
        ],
        "description": "Avisos de vehículo cerca que recibió esa persona en la ventana del incidente. Son avisos, no alertas."
      },
      "ActionBy": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "by": {
            "type": "string",
            "description": "Quién: una persona del panel, o «Cliente · Operador (vía integración)».",
            "example": "Cliente Ejemplo · E. Ejemplo (vía integración)"
          }
        },
        "required": [
          "at",
          "by"
        ]
      },
      "Muster": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "site": {
            "$ref": "#/components/schemas/SiteRef"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "completed",
              "cancelled"
            ],
            "example": "active"
          },
          "phase": {
            "type": "string",
            "enum": [
              "evacuating",
              "inspecting"
            ],
            "description": "`inspecting`: recuento completo y la sala revisa la planta; los relojes dicen «no vuelvas hasta nuevo aviso».",
            "example": "evacuating"
          },
          "kind": {
            "$ref": "#/components/schemas/MusterKind"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "example": "Fuga en Mantenimiento"
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "ended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "deadline": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "at": {
                "type": "string",
                "format": "date-time",
                "example": "2026-09-24T11:04:12Z"
              },
              "source": {
                "type": "string",
                "enum": [
                  "official",
                  "plan_estimate"
                ],
                "description": "Tsunami: hora de un aviso oficial, o el techo del plan de la zona. Nunca un cálculo físico."
              }
            },
            "required": [
              "at",
              "source"
            ]
          },
          "origin": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "latitude": {
                "type": "number",
                "example": 39.97894
              },
              "longitude": {
                "type": "number",
                "example": -2.96877
              },
              "radius_m": {
                "type": "integer",
                "example": 400
              },
              "label": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "Mantenimiento — permiso PT-0453"
              },
              "zone": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/ZoneRef"
                  },
                  {
                    "description": "Si el origen es una zona entera: el radio cuenta desde su borde."
                  }
                ]
              }
            },
            "required": [
              "latitude",
              "longitude",
              "radius_m",
              "label",
              "zone"
            ],
            "description": "De dónde viene la emergencia. `null` en una evacuación sin origen (un simulacro general)."
          },
          "targets_revision": {
            "type": "integer",
            "description": "Sube cada vez que cambian los destinos (descarte, rehabilitación, cambio de tipo).",
            "example": 3
          },
          "totals": {
            "type": "object",
            "properties": {
              "expected": {
                "type": "integer"
              },
              "safe": {
                "type": "integer"
              },
              "help": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "wrong_point": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "pending": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "no_signal": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "not_worn": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            },
            "required": [
              "expected",
              "safe",
              "help",
              "wrong_point",
              "pending",
              "no_signal",
              "not_worn"
            ],
            "description": "Las mismas cifras que el banner del panel y el cierre: las cuenta el servidor. En una evacuación cerrada queda lo que se registró al cerrar —llegados y total—, y el resto sale `null`."
          },
          "assembly_points": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AssemblyPoint"
            },
            "description": "Los destinos del tipo, descartados incluidos."
          }
        },
        "required": [
          "id",
          "site",
          "status",
          "phase",
          "kind",
          "reason",
          "started_at",
          "ended_at",
          "deadline",
          "origin",
          "targets_revision",
          "totals",
          "assembly_points"
        ],
        "example": {
          "id": "4fb94227-7afb-442d-a672-6fd82069518f",
          "site": {
            "id": "11111111-1111-1111-1111-111111111111",
            "name": "Planta Ejemplo — simulación"
          },
          "status": "active",
          "phase": "evacuating",
          "kind": "general",
          "reason": "Fuga en Mantenimiento",
          "started_at": "2026-09-24T18:31:06Z",
          "ended_at": null,
          "deadline": null,
          "origin": {
            "latitude": 39.97894,
            "longitude": -2.96877,
            "radius_m": 400,
            "label": "Mantenimiento — permiso PT-0453",
            "zone": {
              "id": "22222222-2222-2222-2222-222222222203",
              "name": "Mantenimiento — permiso PT-0453"
            }
          },
          "targets_revision": 3,
          "totals": {
            "expected": 9,
            "safe": 6,
            "help": 0,
            "wrong_point": 0,
            "pending": 2,
            "no_signal": 0,
            "not_worn": 1
          },
          "assembly_points": [
            {
              "id": "22222222-2222-2222-2222-222222222204",
              "name": "Punto de reunión PR-1",
              "status": "excluded",
              "count": 0,
              "excluded": {
                "reason": "A 371 m del origen (radio 400 m)",
                "recommended": true,
                "at": "2026-09-24T18:31:06Z",
                "by": "Elena Ejemplo"
              }
            },
            {
              "id": "22222222-2222-2222-2222-222222222205",
              "name": "Punto de reunión PR-2",
              "status": "excluded",
              "count": 0,
              "excluded": {
                "reason": "A sotavento: viento del SO a 9 km/h",
                "recommended": true,
                "at": "2026-09-24T18:31:42Z",
                "by": "Elena Ejemplo"
              }
            },
            {
              "id": "f6040b41-be81-4bf3-af68-bfaf3e284c54",
              "name": "Punto de reunión PR-3 · Zona alta",
              "status": "available",
              "count": 6,
              "excluded": null
            }
          ]
        }
      },
      "MusterKind": {
        "type": "string",
        "enum": [
          "general",
          "tsunami",
          "earthquake"
        ]
      },
      "AssemblyPoint": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "name": {
            "type": "string",
            "example": "Punto de reunión PR-2"
          },
          "status": {
            "type": "string",
            "enum": [
              "available",
              "excluded"
            ],
            "example": "available"
          },
          "count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Personas a salvo en este punto. `null` en una evacuación cerrada: al cerrar solo se guarda el total.",
            "example": 115
          },
          "excluded": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "reason": {
                "type": "string",
                "example": "A sotavento: viento del SO a 9 km/h"
              },
              "recommended": {
                "type": "boolean",
                "description": "Lo había recomendado el sistema (radio o viento). Quien descarta es siempre una persona.",
                "example": true
              },
              "at": {
                "type": "string",
                "format": "date-time",
                "example": "2026-09-24T11:04:12Z"
              },
              "by": {
                "type": "string",
                "example": "Elena Ejemplo"
              }
            },
            "required": [
              "reason",
              "recommended",
              "at",
              "by"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "status",
          "count",
          "excluded"
        ]
      },
      "TokenResponse": {
        "type": "object",
        "properties": {
          "access_token": {
            "type": "string",
            "example": "at_9c1e…"
          },
          "token_type": {
            "type": "string",
            "enum": [
              "Bearer"
            ]
          },
          "expires_in": {
            "type": "number",
            "enum": [
              3600
            ]
          },
          "scope": {
            "type": "string",
            "example": "alerts:read alerts:write musters:read"
          }
        },
        "required": [
          "access_token",
          "token_type",
          "expires_in",
          "scope"
        ]
      },
      "OAuthError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "invalid_request",
              "invalid_client",
              "unauthorized_client",
              "unsupported_grant_type",
              "invalid_scope"
            ]
          },
          "error_description": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ],
        "description": "Error del token, en el formato de RFC 6749 §5.2."
      },
      "TokenRequest": {
        "type": "object",
        "properties": {
          "grant_type": {
            "type": "string",
            "enum": [
              "client_credentials"
            ]
          },
          "client_id": {
            "type": "string",
            "example": "cliente-ejemplo"
          },
          "client_secret": {
            "type": "string",
            "example": "cs_2b7a…"
          },
          "scope": {
            "type": "string",
            "description": "Separados por espacios; subconjunto de los del cliente. Sin él, todos los del cliente.",
            "example": "alerts:read alerts:write musters:read"
          }
        },
        "required": [
          "grant_type",
          "client_id",
          "client_secret"
        ]
      },
      "SiteList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Site"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "Site": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "name": {
            "type": "string",
            "example": "Planta Ejemplo — simulación"
          },
          "location": {
            "type": [
              "string",
              "null"
            ],
            "example": "Polígono industrial de ejemplo"
          },
          "privacy_mode": {
            "type": "string",
            "enum": [
              "continuous",
              "on_demand"
            ],
            "description": "`on_demand` («Solo en emergencia»): la posición de una persona solo se ve con una alerta abierta desde `unveil_min_severity`, en evacuación o con una solicitud aprobada. La API obedece la misma regla que el panel.",
            "example": "on_demand"
          },
          "unveil_min_severity": {
            "$ref": "#/components/schemas/Severity"
          }
        },
        "required": [
          "id",
          "name",
          "location",
          "privacy_mode",
          "unveil_min_severity"
        ]
      },
      "Problem": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "URI que identifica el tipo de problema.",
            "example": "https://api.safeonuba.com/problems/insufficient-scope"
          },
          "title": {
            "type": "string",
            "example": "Falta un permiso del token"
          },
          "status": {
            "type": "integer",
            "example": 403
          },
          "detail": {
            "type": "string",
            "example": "Esta petición necesita el scope alerts:write."
          },
          "instance": {
            "type": "string",
            "example": "/v1/alerts/0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "code": {
            "type": "string",
            "description": "Código estable del error, para programar contra él. El texto puede cambiar; esto no.",
            "example": "insufficient_scope"
          }
        },
        "required": [
          "type",
          "title",
          "status",
          "code"
        ],
        "description": "Error en formato RFC 9457 (`application/problem+json`), con el código HTTP real."
      },
      "ZoneList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Zone"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "Zone": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "name": {
            "type": "string",
            "example": "Punto de reunión PR-3 · Zona alta"
          },
          "type": {
            "type": "string",
            "enum": [
              "work_area",
              "restricted",
              "forbidden",
              "assembly_point",
              "privacy",
              "building"
            ],
            "example": "assembly_point"
          },
          "geometry": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "Polygon"
                ]
              },
              "coordinates": {
                "type": "array",
                "items": {
                  "type": "array",
                  "items": {
                    "type": "array",
                    "prefixItems": [
                      {
                        "type": "number"
                      },
                      {
                        "type": "number"
                      }
                    ]
                  }
                }
              }
            },
            "required": [
              "type",
              "coordinates"
            ],
            "description": "Polígono GeoJSON (WGS84). `null` en las zonas de privacidad: se da el nombre y el tipo, no dónde están (pendiente de validar con el comité)."
          },
          "assembly_point": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "serves": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "general",
                    "tsunami",
                    "earthquake"
                  ]
                }
              },
              "elevation_m": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "elevation_source": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "dem",
                  "verified",
                  null
                ]
              },
              "vertical": {
                "type": "boolean",
                "description": "Refugio en altura (un edificio)."
              },
              "capacity": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            },
            "required": [
              "serves",
              "elevation_m",
              "elevation_source",
              "vertical",
              "capacity"
            ],
            "description": "Solo en los puntos de reunión."
          }
        },
        "required": [
          "id",
          "name",
          "type",
          "geometry",
          "assembly_point"
        ],
        "example": {
          "id": "f6040b41-be81-4bf3-af68-bfaf3e284c54",
          "name": "Punto de reunión PR-3 · Zona alta",
          "type": "assembly_point",
          "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [
                  -2.9825,
                  40.0081
                ],
                [
                  -2.9818,
                  40.0081
                ],
                [
                  -2.9818,
                  40.0076
                ],
                [
                  -2.9825,
                  40.0076
                ],
                [
                  -2.9825,
                  40.0081
                ]
              ]
            ]
          },
          "assembly_point": {
            "serves": [
              "general",
              "tsunami"
            ],
            "elevation_m": 50,
            "elevation_source": "verified",
            "vertical": false,
            "capacity": 200
          }
        }
      },
      "AlertPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Alert"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor opaco para la página siguiente; `null` si no hay más.",
            "example": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ"
          }
        },
        "required": [
          "data",
          "next_cursor"
        ]
      },
      "AlertUpdate": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "acknowledged",
              "resolved"
            ],
            "example": "acknowledged"
          },
          "resolution_reason": {
            "type": "string",
            "maxLength": 200,
            "description": "Al cerrar: por qué. Texto libre; va a la cronología de la alerta junto a la nota.",
            "example": "Falsa alarma"
          },
          "note": {
            "type": "string",
            "maxLength": 500,
            "example": "Confirmado por radio con el encargado."
          },
          "actor": {
            "$ref": "#/components/schemas/Actor"
          }
        },
        "required": [
          "status",
          "actor"
        ],
        "description": "Dos estados, `acknowledged` y `resolved`, más el operador. Reconocer autoasigna la alerta si nadie la llevaba, como en el panel."
      },
      "Actor": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "example": "E. Ejemplo"
          },
          "external_id": {
            "type": "string",
            "maxLength": 64,
            "example": "OP-117"
          }
        },
        "required": [
          "name"
        ],
        "description": "El operador de la sala del cliente que hace la acción. Obligatorio: «reconocida» mide la respuesta de una persona, y sin nombre la cronología diría solo el del cliente."
      },
      "MusterPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Muster"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor opaco para la página siguiente; `null` si no hay más.",
            "example": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ"
          }
        },
        "required": [
          "data",
          "next_cursor"
        ]
      },
      "MissingWorkerList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MissingWorker"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "MissingWorker": {
        "type": "object",
        "properties": {
          "worker": {
            "$ref": "#/components/schemas/WorkerRef"
          },
          "device": {
            "$ref": "#/components/schemas/DeviceRef"
          },
          "status": {
            "type": "string",
            "enum": [
              "help",
              "wrong_point",
              "pending",
              "no_signal",
              "not_worn"
            ]
          },
          "needs_rescue": {
            "type": "boolean",
            "description": "Tiene un SOS o una caída abierta y no ha llegado: no puede salir por su pie.",
            "example": false
          },
          "help_reason": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "trapped",
              "injured",
              "helping",
              null
            ]
          },
          "assembly_point": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
              },
              "name": {
                "type": "string"
              },
              "valid": {
                "type": "boolean"
              }
            },
            "required": [
              "id",
              "name",
              "valid"
            ],
            "description": "El punto donde está o dijo estar (con `wrong_point`, uno que no sirve)."
          },
          "declared_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "last_seen_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          },
          "location": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Location"
              },
              {
                "description": "Con el scope `positions:read`."
              }
            ]
          },
          "location_withheld": {
            "type": "boolean"
          }
        },
        "required": [
          "worker",
          "device",
          "status",
          "needs_rescue",
          "help_reason",
          "assembly_point",
          "declared_at",
          "last_seen_at",
          "location",
          "location_withheld"
        ],
        "example": {
          "worker": {
            "id": "33333333-3333-3333-3333-333333333305",
            "external_id": "EMP-004512",
            "contractor": "Contrata Ejemplo A"
          },
          "device": {
            "id": "44444444-4444-4444-4444-444444444405",
            "serial": "OS-W-0005",
            "battery": 61,
            "date_last_seen": "2026-09-24T18:33:40Z"
          },
          "status": "wrong_point",
          "needs_rescue": false,
          "help_reason": null,
          "assembly_point": {
            "id": "22222222-2222-2222-2222-222222222205",
            "name": "Punto de reunión PR-2",
            "valid": false
          },
          "declared_at": "2026-09-24T18:32:10Z",
          "last_seen_at": "2026-09-24T18:33:40Z",
          "location": null,
          "location_withheld": false
        }
      },
      "WorkerPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Worker"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor opaco para la página siguiente; `null` si no hay más.",
            "example": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ"
          }
        },
        "required": [
          "data",
          "next_cursor"
        ]
      },
      "Worker": {
        "allOf": [
          {
            "$ref": "#/components/schemas/WorkerRef"
          },
          {
            "type": "object",
            "properties": {
              "role": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "Andamiero"
              },
              "is_active": {
                "type": "boolean"
              },
              "device": {
                "$ref": "#/components/schemas/DeviceRef"
              },
              "date_last_seen": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "example": "2026-09-24T11:04:12Z",
                "description": "La más reciente entre la última posición y el último contacto del reloj."
              }
            },
            "required": [
              "role",
              "is_active",
              "device",
              "date_last_seen"
            ]
          }
        ]
      },
      "DevicePage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Device"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor opaco para la página siguiente; `null` si no hay más.",
            "example": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ"
          }
        },
        "required": [
          "data",
          "next_cursor"
        ]
      },
      "Device": {
        "allOf": [
          {
            "$ref": "#/components/schemas/DeviceRef"
          },
          {
            "type": "object",
            "properties": {
              "model": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "Galaxy Watch6"
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "inactive",
                  "maintenance",
                  "lost"
                ],
                "example": "active"
              },
              "worn": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "`null`: no se sabe (sin sensor de muñeca).",
                "example": true
              },
              "worker": {
                "$ref": "#/components/schemas/WorkerRef"
              }
            },
            "required": [
              "model",
              "status",
              "worn",
              "worker"
            ]
          }
        ]
      },
      "PositionList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Position"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "Position": {
        "type": "object",
        "properties": {
          "device_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "worker": {
            "$ref": "#/components/schemas/WorkerRef"
          },
          "location": {
            "$ref": "#/components/schemas/Location"
          },
          "in_vehicle": {
            "type": "boolean"
          }
        },
        "required": [
          "device_id",
          "worker",
          "location",
          "in_vehicle"
        ],
        "description": "Solo las posiciones que el velo deja ver en ese instante: en «Solo en emergencia», quien no tiene nada abierto no aparece."
      },
      "EventPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Event"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor opaco para la página siguiente; `null` si no hay más.",
            "example": "eyJ0IjoiMjAyNi0wOS0yNFQxMTowNDoxMloifQ"
          }
        },
        "required": [
          "data",
          "next_cursor"
        ]
      },
      "WebhookEndpointList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEndpoint"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "anyOf": [
                {
                  "$ref": "#/components/schemas/EventType"
                },
                {
                  "type": "string",
                  "enum": [
                    "alert.*",
                    "muster.*"
                  ]
                }
              ]
            }
          },
          "filters": {
            "type": "object",
            "properties": {
              "site_ids": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "uuid",
                  "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
                }
              },
              "min_severity": {
                "$ref": "#/components/schemas/Severity"
              },
              "contractors": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "is_active": {
            "type": "boolean"
          },
          "failing_since": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z",
            "description": "Lleva fallando desde entonces. El destino **no** se desactiva solo: dejaría de llegar el siguiente SOS sin que nadie lo decida."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          }
        },
        "required": [
          "id",
          "url",
          "event_types",
          "filters",
          "is_active",
          "failing_since",
          "created_at"
        ]
      },
      "WebhookEndpointCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/WebhookEndpoint"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "El secreto de firma. **Solo se enseña esta vez.**",
                "example": "whsec_6f1c0d9e2b7a4c3f8e5d1a0b9c8e7f6a"
              }
            },
            "required": [
              "secret"
            ]
          }
        ]
      },
      "WebhookEndpointCreate": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "example": "https://sala.cliente.example/safeonuba"
          },
          "event_types": {
            "type": "array",
            "items": {
              "anyOf": [
                {
                  "$ref": "#/components/schemas/EventType"
                },
                {
                  "type": "string",
                  "enum": [
                    "alert.*",
                    "muster.*"
                  ]
                }
              ]
            },
            "minItems": 1,
            "example": [
              "alert.*",
              "muster.*"
            ]
          },
          "filters": {
            "type": "object",
            "properties": {
              "site_ids": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "uuid",
                  "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
                }
              },
              "min_severity": {
                "$ref": "#/components/schemas/Severity"
              },
              "contractors": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          }
        },
        "required": [
          "url",
          "event_types"
        ]
      },
      "WebhookSecretRotated": {
        "type": "object",
        "properties": {
          "secret": {
            "type": "string",
            "example": "whsec_6f1c0d9e2b7a4c3f8e5d1a0b9c8e7f6a"
          },
          "previous_valid_until": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z"
          }
        },
        "required": [
          "secret",
          "previous_valid_until"
        ]
      },
      "SandboxScenarioRun": {
        "type": "object",
        "properties": {
          "run_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "scenario": {
            "$ref": "#/components/schemas/SandboxScenario"
          },
          "site_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
          },
          "resource": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "alert",
                  "muster"
                ]
              },
              "id": {
                "type": "string",
                "format": "uuid",
                "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
              }
            },
            "required": [
              "type",
              "id"
            ],
            "description": "Lo que ha abierto: consultadlo con su `GET`, o esperad el webhook."
          },
          "ends_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T11:04:12Z",
            "description": "Cuándo se cierra solo."
          }
        },
        "required": [
          "run_id",
          "scenario",
          "site_id",
          "resource",
          "ends_at"
        ]
      },
      "SandboxScenario": {
        "type": "string",
        "enum": [
          "sos",
          "fall",
          "heat_stress",
          "evacuation"
        ],
        "description": "- `sos`, `fall`, `heat_stress`: una persona de prueba pulsa el SOS, se cae o sufre estrés térmico. Sale la alerta (`alert.created`) y, si nadie la cierra antes con `PUT /v1/alerts/{id}`, se cierra sola a los 10 min (`alert.resolved`).\n- `evacuation`: una evacuación general de 3 min. A los 15 s llegan cuatro personas al punto A; a los 35 s alguien llega al B y la sala lo descarta (`muster.targets_changed`); a los 55 s una persona pide ayuda (`alert.created`, `evacuation_help`); a los 95 s quien estaba en B se va al C; a los 3 min, todo despejado (`muster.ended`). Entre medias, `muster.updated` cada vez que cambia el recuento."
      },
      "SandboxScenarioRequest": {
        "type": "object",
        "properties": {
          "scenario": {
            "$ref": "#/components/schemas/SandboxScenario"
          },
          "site_id": {
            "type": "string",
            "format": "uuid",
            "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
            "description": "El centro de sandbox. Sin él, el primero de los vuestros que sea de sandbox."
          }
        },
        "required": [
          "scenario"
        ]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/v1/oauth/token": {
      "post": {
        "operationId": "createToken",
        "security": [],
        "tags": [
          "Autenticación"
        ],
        "summary": "Pedir un token (client_credentials)",
        "description": "OAuth2 `client_credentials`. El token es opaco, dura una hora y se revoca al instante al dar de baja al cliente.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "$ref": "#/components/schemas/TokenRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token emitido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          },
          "400": {
            "description": "Petición mal formada o scope no permitido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "Cliente o secreto incorrectos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sites": {
      "get": {
        "operationId": "listSites",
        "tags": [
          "Centros"
        ],
        "summary": "Centros del cliente",
        "security": [
          {
            "oauth2": []
          }
        ],
        "description": "**Scope:** cualquiera.",
        "responses": {
          "200": {
            "description": "Centros a los que accede el cliente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteList"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sites/{id}/zones": {
      "get": {
        "operationId": "listSiteZones",
        "tags": [
          "Centros"
        ],
        "summary": "Zonas y puntos de reunión de un centro",
        "description": "GeoJSON en WGS84. Las zonas de privacidad, con nombre y tipo pero sin geometría.\n\n**Scope:** `alerts:read`.",
        "security": [
          {
            "oauth2": [
              "alerts:read"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id del centro."
            },
            "required": true,
            "description": "Id del centro.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "Zonas en vigor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneList"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "El centro no existe o no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/alerts": {
      "get": {
        "operationId": "listAlerts",
        "tags": [
          "Alertas"
        ],
        "summary": "Listar alertas",
        "security": [
          {
            "oauth2": [
              "alerts:read"
            ]
          }
        ],
        "description": "**Scope:** `alerts:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
            },
            "required": false,
            "name": "site_id",
            "in": "query"
          },
          {
            "schema": {
              "$ref": "#/components/schemas/AlertType"
            },
            "required": false,
            "description": "Qué ha pasado. `earthquake` y `tsunami_risk` son de la planta entera: no llevan `worker` ni `device`. Un vehículo cerca **no** es una alerta: sale dentro de la alerta de un incidente (`nearby`, `vehicle_warnings`).",
            "name": "type",
            "in": "query"
          },
          {
            "schema": {
              "$ref": "#/components/schemas/AlertStatus"
            },
            "required": false,
            "description": "`acknowledged`: un operador se ha hecho cargo. No es lo mismo que `worker_acknowledged_at`, que es que el trabajador ha visto el aviso en su reloj.",
            "name": "status",
            "in": "query"
          },
          {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Severity"
                },
                {
                  "description": "Gravedad mínima."
                }
              ]
            },
            "required": false,
            "description": "Gravedad mínima.",
            "name": "severity",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "format": "date-time",
              "example": "2026-09-24T11:04:12Z"
            },
            "required": false,
            "name": "since",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "format": "date-time",
              "example": "2026-09-24T11:04:12Z"
            },
            "required": false,
            "name": "until",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100,
              "description": "Elementos por página (máximo 500).",
              "example": 100
            },
            "required": false,
            "description": "Elementos por página (máximo 500).",
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "El `next_cursor` de la página anterior."
            },
            "required": false,
            "description": "El `next_cursor` de la página anterior.",
            "name": "cursor",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Alertas, de la más reciente a la más antigua.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AlertPage"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/alerts/{id}": {
      "get": {
        "operationId": "getAlert",
        "tags": [
          "Alertas"
        ],
        "summary": "Detalle de una alerta",
        "security": [
          {
            "oauth2": [
              "alerts:read"
            ]
          }
        ],
        "description": "**Scope:** `alerts:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id de la alerta."
            },
            "required": true,
            "description": "Id de la alerta.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "La alerta.",
            "content": {
              "application/json": {
                "examples": {
                  "forbidden_zone_entry": {
                    "value": {
                      "id": "7a1e9c40-52d8-4b0f-a3c6-2e9d81f0b7a4",
                      "type": "forbidden_zone_entry",
                      "severity": "high",
                      "status": "unacknowledged",
                      "title": "Entrada en zona prohibida — Almacenamiento H₂ (ATEX) — EMP-003318",
                      "date_created": "2026-09-24T10:12:40Z",
                      "date_last_modified": "2026-09-24T10:12:40Z",
                      "site": {
                        "id": "11111111-1111-1111-1111-111111111111",
                        "name": "Planta Ejemplo — simulación"
                      },
                      "zone": {
                        "id": "22222222-2222-2222-2222-222222222202",
                        "name": "Almacenamiento H₂ (ATEX)"
                      },
                      "worker": {
                        "id": "33333333-3333-3333-3333-333333333303",
                        "external_id": "EMP-003318",
                        "contractor": "Contrata Ejemplo C"
                      },
                      "device": {
                        "id": "44444444-4444-4444-4444-444444444403",
                        "serial": "OS-W-0003",
                        "battery": 81,
                        "date_last_seen": "2026-09-24T10:12:38Z"
                      },
                      "location": {
                        "latitude": 40.00344,
                        "longitude": -3.00561,
                        "uncertainty_m": 5,
                        "source": "gps",
                        "masked": false,
                        "position_date_utc": "2026-09-24T10:12:36Z",
                        "height": {
                          "height_m": 7.6,
                          "uncertainty_m": 1,
                          "building": "Nave de proceso",
                          "floor": "Planta 1"
                        }
                      },
                      "location_withheld": false,
                      "details": {
                        "zone_type": "forbidden",
                        "permit_reference": null
                      },
                      "nearby": [],
                      "vehicle_warnings": [],
                      "worker_acknowledged_at": "2026-09-24T10:12:51Z",
                      "acknowledged": null,
                      "resolved": null,
                      "assigned_to": null,
                      "simulated": true,
                      "panel_url": "https://app.safeonuba.com/alertas/7a1e9c40-52d8-4b0f-a3c6-2e9d81f0b7a4"
                    }
                  },
                  "heat_stress": {
                    "value": {
                      "id": "c3b58f21-0e7a-4d19-8f6b-5a2c9e417d03",
                      "type": "heat_stress",
                      "severity": "high",
                      "status": "acknowledged",
                      "title": "Estrés térmico — parar y descansar",
                      "date_created": "2026-09-24T13:41:05Z",
                      "date_last_modified": "2026-09-24T13:43:30Z",
                      "site": {
                        "id": "11111111-1111-1111-1111-111111111111",
                        "name": "Planta Ejemplo — simulación"
                      },
                      "zone": null,
                      "worker": {
                        "id": "33333333-3333-3333-3333-333333333305",
                        "external_id": "EMP-004512",
                        "contractor": "Contrata Ejemplo A"
                      },
                      "device": {
                        "id": "44444444-4444-4444-4444-444444444405",
                        "serial": "OS-W-0005",
                        "battery": 58,
                        "date_last_seen": "2026-09-24T13:41:02Z"
                      },
                      "location": {
                        "latitude": 39.99954,
                        "longitude": -3.00377,
                        "uncertainty_m": 6,
                        "source": "gps",
                        "masked": false,
                        "position_date_utc": "2026-09-24T13:41:00Z",
                        "height": null
                      },
                      "location_withheld": false,
                      "details": {
                        "action": "stop_and_rest",
                        "heat_index_c": 33,
                        "sun_exposure_minutes": 91
                      },
                      "nearby": [],
                      "vehicle_warnings": [],
                      "worker_acknowledged_at": "2026-09-24T13:41:20Z",
                      "acknowledged": {
                        "at": "2026-09-24T13:43:30Z",
                        "by": "Cliente Ejemplo · E. Ejemplo (vía integración)"
                      },
                      "resolved": null,
                      "assigned_to": "Berta Ejemplo",
                      "simulated": true,
                      "panel_url": "https://app.safeonuba.com/alertas/c3b58f21-0e7a-4d19-8f6b-5a2c9e417d03"
                    }
                  },
                  "evacuation_help": {
                    "value": {
                      "id": "5d02c7a9-1b3e-4f80-9c24-6e8a0d1f3b57",
                      "type": "evacuation_help",
                      "severity": "critical",
                      "status": "unacknowledged",
                      "title": "No puede evacuar: atrapada — EMP-004512",
                      "date_created": "2026-09-24T11:12:30Z",
                      "date_last_modified": "2026-09-24T11:12:30Z",
                      "site": {
                        "id": "11111111-1111-1111-1111-111111111111",
                        "name": "Planta Ejemplo — simulación"
                      },
                      "zone": null,
                      "worker": {
                        "id": "33333333-3333-3333-3333-333333333305",
                        "external_id": "EMP-004512",
                        "contractor": "Contrata Ejemplo A"
                      },
                      "device": {
                        "id": "44444444-4444-4444-4444-444444444405",
                        "serial": "OS-W-0005",
                        "battery": 61,
                        "date_last_seen": "2026-09-24T11:12:28Z"
                      },
                      "location": {
                        "latitude": 39.99871,
                        "longitude": -2.99905,
                        "uncertainty_m": 12,
                        "source": "network",
                        "masked": false,
                        "position_date_utc": "2026-09-24T11:12:25Z",
                        "height": null
                      },
                      "location_withheld": false,
                      "details": {
                        "reason": "trapped",
                        "muster_id": "14df4773-88a3-4df5-811e-81cdc8ef12bb"
                      },
                      "nearby": [],
                      "vehicle_warnings": [],
                      "worker_acknowledged_at": null,
                      "acknowledged": null,
                      "resolved": null,
                      "assigned_to": null,
                      "simulated": false,
                      "panel_url": "https://app.safeonuba.com/alertas/5d02c7a9-1b3e-4f80-9c24-6e8a0d1f3b57"
                    }
                  },
                  "low_battery": {
                    "value": {
                      "id": "9e4f1a6b-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
                      "type": "low_battery",
                      "severity": "medium",
                      "status": "unacknowledged",
                      "title": "Batería baja — EMP-002210",
                      "date_created": "2026-09-24T15:20:00Z",
                      "date_last_modified": "2026-09-24T15:20:00Z",
                      "site": {
                        "id": "11111111-1111-1111-1111-111111111111",
                        "name": "Planta Ejemplo — simulación"
                      },
                      "zone": null,
                      "worker": {
                        "id": "33333333-3333-3333-3333-333333333309",
                        "external_id": "EMP-002210",
                        "contractor": "Contrata Ejemplo B"
                      },
                      "device": {
                        "id": "44444444-4444-4444-4444-444444444409",
                        "serial": "OS-W-0009",
                        "battery": 12,
                        "date_last_seen": "2026-09-24T15:19:55Z"
                      },
                      "location": null,
                      "location_withheld": true,
                      "details": {
                        "battery_pct": 12
                      },
                      "nearby": [],
                      "vehicle_warnings": [],
                      "worker_acknowledged_at": null,
                      "acknowledged": null,
                      "resolved": null,
                      "assigned_to": null,
                      "simulated": true,
                      "panel_url": "https://app.safeonuba.com/alertas/9e4f1a6b-2c3d-4e5f-8a9b-0c1d2e3f4a5b"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/Alert"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe, o esta identidad no la ve (otro centro, otra contrata).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "updateAlert",
        "tags": [
          "Alertas"
        ],
        "summary": "Reconocer o cerrar una alerta",
        "description": "La cronología de la alerta escribe «Cliente · Operador (vía integración)». Reconocer no es cerrar: una alerta reconocida sigue marcando a la persona en el mapa hasta que se cierra.\n\n**Scope:** `alerts:write`.",
        "security": [
          {
            "oauth2": [
              "alerts:write"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id de la alerta."
            },
            "required": true,
            "description": "Id de la alerta.",
            "name": "id",
            "in": "path"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AlertUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "La alerta, ya actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Alert"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe, o esta identidad no la ve.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "description": "La transición no es posible (por ejemplo, reconocer una alerta ya cerrada).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "description": "El cuerpo no es válido.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/musters": {
      "get": {
        "operationId": "listMusters",
        "tags": [
          "Evacuaciones"
        ],
        "summary": "Listar evacuaciones",
        "security": [
          {
            "oauth2": [
              "musters:read"
            ]
          }
        ],
        "description": "**Scope:** `musters:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
            },
            "required": false,
            "name": "site_id",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "completed",
                "cancelled"
              ]
            },
            "required": false,
            "name": "status",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "format": "date-time",
              "example": "2026-09-24T11:04:12Z"
            },
            "required": false,
            "name": "since",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100,
              "description": "Elementos por página (máximo 500).",
              "example": 100
            },
            "required": false,
            "description": "Elementos por página (máximo 500).",
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "El `next_cursor` de la página anterior."
            },
            "required": false,
            "description": "El `next_cursor` de la página anterior.",
            "name": "cursor",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Evacuaciones activas e históricas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MusterPage"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/musters/{id}": {
      "get": {
        "operationId": "getMuster",
        "tags": [
          "Evacuaciones"
        ],
        "summary": "Estado y recuento de una evacuación",
        "description": "El recuento lo cuenta el servidor con la misma regla que el panel y el cierre: posición precisa dentro de un punto válido, o llegada declarada a uno.\n\n**Scope:** `musters:read`.",
        "security": [
          {
            "oauth2": [
              "musters:read"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id de la evacuación."
            },
            "required": true,
            "description": "Id de la evacuación.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "La evacuación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Muster"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe o no es de un centro del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/musters/{id}/missing": {
      "get": {
        "operationId": "listMissingWorkers",
        "tags": [
          "Evacuaciones"
        ],
        "summary": "Quién falta",
        "description": "Primero quien pide ayuda o necesita rescate, después quien está en un punto que no sirve, sin señal, sin el reloj puesto y saliendo. La última posición solo con el scope `positions:read`, y si el velo la deja ver.\n\n**Scope:** `musters:read`.",
        "security": [
          {
            "oauth2": [
              "musters:read"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id de la evacuación."
            },
            "required": true,
            "description": "Id de la evacuación.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "Quién falta, por urgencia.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MissingWorkerList"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe o no es de un centro del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/workers": {
      "get": {
        "operationId": "listWorkers",
        "tags": [
          "Trabajadores"
        ],
        "summary": "Listar trabajadores",
        "security": [
          {
            "oauth2": [
              "workers:read"
            ]
          }
        ],
        "description": "**Scope:** `workers:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
            },
            "required": false,
            "name": "site_id",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100,
              "description": "Elementos por página (máximo 500).",
              "example": 100
            },
            "required": false,
            "description": "Elementos por página (máximo 500).",
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "El `next_cursor` de la página anterior."
            },
            "required": false,
            "description": "El `next_cursor` de la página anterior.",
            "name": "cursor",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Trabajadores.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkerPage"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/workers/{id}": {
      "get": {
        "operationId": "getWorker",
        "tags": [
          "Trabajadores"
        ],
        "summary": "Detalle de un trabajador",
        "security": [
          {
            "oauth2": [
              "workers:read"
            ]
          }
        ],
        "description": "**Scope:** `workers:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id del trabajador."
            },
            "required": true,
            "description": "Id del trabajador.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "El trabajador.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Worker"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe o no lo ve esta identidad.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/devices": {
      "get": {
        "operationId": "listDevices",
        "tags": [
          "Trabajadores"
        ],
        "summary": "Listar relojes",
        "security": [
          {
            "oauth2": [
              "workers:read"
            ]
          }
        ],
        "description": "**Scope:** `workers:read`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
            },
            "required": false,
            "name": "site_id",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100,
              "description": "Elementos por página (máximo 500).",
              "example": 100
            },
            "required": false,
            "description": "Elementos por página (máximo 500).",
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "El `next_cursor` de la página anterior."
            },
            "required": false,
            "description": "El `next_cursor` de la página anterior.",
            "name": "cursor",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Relojes: batería, si está puesto, última conexión.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DevicePage"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/positions/latest": {
      "get": {
        "operationId": "listLatestPositions",
        "tags": [
          "Posiciones"
        ],
        "summary": "Últimas posiciones visibles",
        "description": "Solo las que el velo deja ver ahora. Cada posición servida con una alerta detrás queda registrada en la alerta («Quién ha visto su posición»).\n\n**Scope:** `positions:read`.",
        "security": [
          {
            "oauth2": [
              "positions:read"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90"
            },
            "required": false,
            "name": "site_id",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Posiciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PositionList"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/events": {
      "get": {
        "operationId": "listEvents",
        "tags": [
          "Eventos"
        ],
        "summary": "Reproducir eventos",
        "description": "Los mismos que llegan por webhook, guardados 30 días: para recuperar lo perdido tras una caída del receptor.\n\n**Scope:** cualquiera.",
        "security": [
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "date-time",
              "example": "2026-09-24T11:04:12Z",
              "description": "Desde cuándo (como mucho 30 días atrás)."
            },
            "required": true,
            "description": "Desde cuándo (como mucho 30 días atrás).",
            "name": "since",
            "in": "query"
          },
          {
            "schema": {
              "$ref": "#/components/schemas/EventType"
            },
            "required": false,
            "description": "`muster.updated`: el recuento ha cambiado (se comprueba cada 5 s, solo se emite si cambia). `muster.targets_changed`: la sala ha descartado o rehabilitado un punto de reunión.",
            "name": "type",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100,
              "description": "Elementos por página (máximo 500).",
              "example": 100
            },
            "required": false,
            "description": "Elementos por página (máximo 500).",
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "El `next_cursor` de la página anterior."
            },
            "required": false,
            "description": "El `next_cursor` de la página anterior.",
            "name": "cursor",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Eventos, del más antiguo al más reciente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventPage"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "tags": [
          "Webhooks"
        ],
        "summary": "Listar destinos",
        "security": [
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "description": "**Scope:** `webhooks:manage`.",
        "responses": {
          "200": {
            "description": "Destinos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpointList"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Crear un destino",
        "security": [
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "description": "**Scope:** `webhooks:manage`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEndpointCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Destino creado, con su secreto de firma (solo esta vez).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpointCreated"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "description": "El cuerpo no es válido (la URL tiene que ser https).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "delete": {
        "operationId": "deleteWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Borrar un destino",
        "security": [
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "description": "**Scope:** `webhooks:manage`.",
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id del destino."
            },
            "required": true,
            "description": "Id del destino.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "204": {
            "description": "Borrado."
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks/{id}/rotate-secret": {
      "post": {
        "operationId": "rotateWebhookSecret",
        "tags": [
          "Webhooks"
        ],
        "summary": "Rotar el secreto de firma",
        "description": "Da un secreto nuevo. El anterior sigue firmando 24 h: durante ese tiempo cada envío lleva las dos firmas en `X-SafeOnuba-Signature`, separadas por coma, así que el receptor puede cambiar de secreto sin cortar. Una segunda rotación retira el primero: nunca hay más de dos.\n\n**Scope:** `webhooks:manage`.",
        "security": [
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id del destino."
            },
            "required": true,
            "description": "Id del destino.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "El secreto nuevo (solo esta vez) y hasta cuándo firma el anterior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSecretRotated"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks/{id}/test": {
      "post": {
        "operationId": "testWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Enviar un evento de prueba",
        "description": "Manda un `alert.created` marcado `simulated: true`, firmado como uno real.\n\n**Scope:** `webhooks:manage`.",
        "security": [
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "0f6c2b7e-3a1d-4c55-9b0e-7d2f4a8c1e90",
              "description": "Id del destino."
            },
            "required": true,
            "description": "Id del destino.",
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "202": {
            "description": "En cola: el resultado se ve en el destino (`failing_since`)."
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No existe.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sandbox/scenarios": {
      "post": {
        "operationId": "runSandboxScenario",
        "tags": [
          "Sandbox"
        ],
        "summary": "Lanzar un escenario de prueba",
        "description": "**Solo en `sandbox.api.safeonuba.com`**; en producción, 404. Un escenario no inventa alertas: relojes simulados de un centro de sandbox mandan sus hechos y el motor de siempre decide, así que lo que llega —webhook, `GET`, recuento— es lo mismo que llegaría de un reloj de verdad, marcado `simulated: true`. Como mucho cinco abiertos a la vez por cliente y una evacuación por centro.\n\n**Scope:** cualquiera.",
        "security": [
          {
            "oauth2": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SandboxScenarioRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Lanzado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SandboxScenarioRun"
                }
              }
            }
          },
          "401": {
            "description": "Falta el token, ha caducado o se ha revocado.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Al token le falta el scope que pide la ruta, o el centro no es del cliente.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "No es el sandbox, o el centro no es vuestro.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay una evacuación en marcha en ese centro, o cinco escenarios abiertos.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "description": "El centro no es de sandbox, o tiene un reloj de verdad y no corre nada.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones, o demasiado tiempo de consulta: cada cliente tiene un número de peticiones por minuto (600 de serie) y un tiempo de consulta por minuto (30 s de serie), y cada petición gasta lo que tarda, así que una lista larga cuenta más que una alerta suelta. `Retry-After` dice cuándo volver.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "alert.*": {
      "post": {
        "operationId": "alertEvent",
        "security": [],
        "tags": [
          "Webhooks"
        ],
        "summary": "Cambios en una alerta",
        "description": "Un `POST` por evento a cada destino suscrito, con el objeto completo.\n\nCabeceras: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (segundos Unix) y\n`X-SafeOnuba-Signature: v1=<hex(HMAC_SHA256(secreto, timestamp + \".\" + cuerpo))>`. Rechaza\nmás de 5 min de desfase. Durante una rotación llegan dos firmas separadas por coma.\n\nAcuse: cualquier `2xx` en menos de 5 s. Reintentos a los 10 s, 30 s, 2 min, 10 min, 30 min,\n1 h y cada hora hasta 24 h. Entrega «al menos una vez» (deduplica por `id`) y **sin orden\ngarantizado** (usa `date_last_modified`). SOS, caídas, «no puedo evacuar» y evacuaciones salen\nantes que el resto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recibido. Cualquier `2xx` vale."
          }
        }
      }
    },
    "muster.*": {
      "post": {
        "operationId": "musterEvent",
        "security": [],
        "tags": [
          "Webhooks"
        ],
        "summary": "Cambios en una evacuación",
        "description": "Un `POST` por evento a cada destino suscrito, con el objeto completo.\n\nCabeceras: `X-SafeOnuba-Event-Id`, `X-SafeOnuba-Timestamp` (segundos Unix) y\n`X-SafeOnuba-Signature: v1=<hex(HMAC_SHA256(secreto, timestamp + \".\" + cuerpo))>`. Rechaza\nmás de 5 min de desfase. Durante una rotación llegan dos firmas separadas por coma.\n\nAcuse: cualquier `2xx` en menos de 5 s. Reintentos a los 10 s, 30 s, 2 min, 10 min, 30 min,\n1 h y cada hora hasta 24 h. Entrega «al menos una vez» (deduplica por `id`) y **sin orden\ngarantizado** (usa `date_last_modified`). SOS, caídas, «no puedo evacuar» y evacuaciones salen\nantes que el resto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recibido. Cualquier `2xx` vale."
          }
        }
      }
    }
  }
}