Documentación · v2.0.0

API TUSSAM

API REST de código abierto para consultar paradas, líneas y tiempos de llegada de los autobuses de TUSSAM (Sevilla) en tiempo real. Actúa como proxy inteligente sobre la API interna de TUSSAM: normaliza los datos, cachea los tiempos, reintenta con criterio ante los errores del origen y expone CORS para poder llamarse desde cualquier cliente.

Toda la superficie de lectura es pública y no requiere autenticación. Solo los endpoints de sincronización (escritura) están protegidos por una API key. Las respuestas son JSON con coordenadas en grados decimales; el endpoint agregado admite además salida GeoJSON.

Proyecto independiente. Esta API no es oficial ni tiene relación con TUSSAM. Consume su API interna pública de reddelineas.tussam.es de forma responsable (cache y rate limiting) y expone los datos con una interfaz estable.

URL base y formato

En el despliegue con Docker Compose incluido, la API escucha en http://localhost:8081. En producción, la URL base es la de tu propio despliegue. Todos los ejemplos de esta página usan el puerto local.

  • Todas las respuestas son application/json (salvo formato=geojson, que devuelve un FeatureCollection).
  • Las coordenadas se expresan en grados decimales (WGS84), no en el formato E6 del origen.
  • Cada respuesta incluye cabeceras X-RateLimit-* con la cuota restante.
  • Cabeceras de seguridad activas: X-Content-Type-Options, X-Frame-Options, Referrer-Policy y Strict-Transport-Security en producción.

Autenticación

Los endpoints de lectura son públicos. Los de sincronización (POST /sync/*) exigen la cabecera X-API-Key con el valor de la variable de entorno de la clave de sync. La comparación usa hmac.compare_digest para no filtrar información por tiempo de respuesta.

sincronización autenticada
curl -X POST http://localhost:8081/sync/all \
  -H "X-API-Key: $MI_CLAVE_DE_SYNC"

El servicio se niega a arrancar la operación si la clave está sin definir o conserva el valor de ejemplo (responde 503). El flag ALLOW_UNAUTHENTICATED_SYNC permite desactivar la autenticación solo fuera de producción; con APP_ENV=production se rechaza siempre.

Rate limiting

Cada petición se contabiliza en dos cubos que se aplican de forma conjunta: uno por IP (techo anti-DDoS) y otro, más estricto, por dispositivo. Se responde 429 si cualquiera de los dos supera su límite. El identificador de dispositivo solo puede restringir más, nunca saltarse el límite por IP.

ÁmbitoLímiteClave
Por IP300 req/minIP del cliente (real tras proxy de confianza)
Por dispositivo60 req/minCabecera X-Device-ID (opcional)

Un cliente que envíe un X-Device-ID estable (por ejemplo, un UUID generado al instalarse) obtiene su propia cuota de 60/min sin competir con el resto de usuarios de su misma IP. Cada respuesta trae:

  • X-RateLimit-Limit: límite aplicable.
  • X-RateLimit-Remaining: peticiones restantes en la ventana.
  • X-RateLimit-Reset: segundos hasta que se libere cuota.
  • Retry-After (solo en 429): segundos que conviene esperar.
Escalado. Los cubos viven en memoria del proceso. Despliega con un solo worker y escala por réplicas; con varios workers, el límite efectivo se multiplicaría. Para un límite global real, externaliza el estado (Redis). El endpoint /health está exento del rate limiting.

Resiliencia y cache

El origen (reddelineas.tussam.es) devuelve 429 con facilidad y a veces cambia el formato de la respuesta. La API está construida para que el cliente nunca herede esos problemas:

  • Cache fresco (TTL 60 s): los tiempos se sirven desde SQLite; el origen solo se consulta cuando expiran.
  • Single-flight por parada: muchas peticiones simultáneas a la misma parada generan una sola llamada al origen.
  • Reintentos con backoff: los 429 y 5xx se reintentan respetando el Retry-After que envíe el origen.
  • Cache stale (hasta 10 min): si el origen cae, se sirve la última respuesta válida marcada con stale: true y su cached_at.

Los endpoints señalizan siempre el estado del origen para que el cliente distinga «no vienen buses» de «no pudimos consultar»: mediante upstream_status en /paradas/{codigo}/tiempos y tiempos_status por parada en /cercanas.

Referencia de endpoints

GET /cercanas Público

Endpoint principal. Devuelve las paradas más cercanas a una ubicación con sus tiempos de llegada, en una sola llamada. Es el recomendado para apps, webs e integraciones que parten de coordenadas.

Parámetros

ParámetroTipoDef.Descripción
lat obligatoriofloatLatitud del usuario (−90 a 90)
lon obligatoriofloatLongitud del usuario (−180 a 180)
radioint300Radio de búsqueda en metros (50–2000)
max_paradasint3Máximo de paradas a devolver (1–10)
bearingfloatnullOrientación del usuario en grados (0–360)
bearing_tolerancefloat60Tolerancia de orientación (0–180)
tiempo_maxintnullSolo buses que lleguen en ≤ X minutos
lineasstringnullFiltrar líneas separadas por comas (ej: 01,C4)
sentidointnullFiltrar por sentido: 1 (ida) o 2 (vuelta)
formatostringjsonjson o geojson
incluir_mapaboolfalseAñade una URL de OpenStreetMap por parada

Ejemplo

petición
curl "http://localhost:8081/cercanas?lat=37.3896&lon=-5.9842&max_paradas=2"
respuesta 200
{
  "ubicacion": { "lat": 37.3896, "lon": -5.9842, "bearing": null },
  "paradas": [
    {
      "codigo": "43",
      "nombre": "Recaredo (Puerta Carmona)",
      "latitud": 37.389663,
      "longitud": -5.984265,
      "distancia": 74,
      "calle": "Calle Recaredo",
      "numero": "5",
      "codigo_postal": "41003",
      "municipio": "Sevilla",
      "direccion_completa": "Calle Recaredo 5",
      "tiempos_status": "ok",
      "tiempos": [
        { "linea": "01", "color": "#f54129", "tiempo_minutos": 2,
          "destino": "POL. NORTE", "distancia_metros": 420, "sentido": 1 }
      ]
    }
  ]
}

Señalización del estado del origen

Cada parada incluye tiempos_status para que el cliente interprete correctamente una lista de tiempos vacía:

ValorSignificado
okConsulta correcta. La lista vacía significa que no vienen buses.
unavailableEl origen no respondió y no había cache que servir.
errorError inesperado al procesar la parada.

Si los datos provienen de cache antigua, la parada añade stale: true y cached_at con el instante de la última consulta buena.

El máximo de tiempos por parada es 5. Los campos provincia, comunidad_autonoma y updated_at no se incluyen aquí; para obtenerlos usa GET /paradas/{codigo}.
GET /paradas Público

Devuelve el catálogo completo de paradas (967 actualmente) con todos sus campos, incluida la dirección postal. Ordenadas por código.

respuesta 200 (un elemento)
{
  "codigo": "889",
  "nombre": "Recaredo (San Roque)",
  "latitud": 37.391250,
  "longitud": -5.984236,
  "calle": "Calle Recaredo",
  "numero": "5",
  "codigo_postal": "41003",
  "municipio": "Sevilla",
  "provincia": "Sevilla",
  "comunidad_autonoma": "Andalucía",
  "direccion_completa": "Calle Recaredo 5",
  "updated_at": "2026-02-16T20:41:18"
}
GET /paradas/cercanas Público

Paradas cercanas sin tiempos de llegada. Más rápido que /cercanas; útil para pintar marcadores en un mapa. Devuelve todas las paradas dentro del radio (sin límite de número), cada una con distancia, bearing y bearing_diff.

ParámetroTipoDef.Descripción
lat obligatoriofloatLatitud (−90 a 90)
lon obligatoriofloatLongitud (−180 a 180)
radioint500Radio en metros (50–2000). Nota: aquí el defecto es 500.
bearingfloatnullOrientación del usuario (0–360)
bearing_tolerancefloat60Tolerancia en grados (0–180)
GET /paradas/{codigo} Público

Datos completos de una parada por su código. El código debe ser numérico (1–7 dígitos); un formato inválido se rechaza con 422 y una parada inexistente con 404.

petición
curl http://localhost:8081/paradas/43
GET /paradas/{codigo}/tiempos Público

Tiempos de llegada en tiempo real de una parada concreta. A diferencia de /cercanas, aquí la respuesta se ajusta al modelo TiemposParadaOut con metadatos explícitos de frescura y estado del origen.

respuesta 200
{
  "parada": "43",
  "nombre": "Recaredo (Puerta Carmona)",
  "latitud": 37.389663,
  "longitud": -5.984265,
  "tiempos": [
    { "linea": "01", "color": "#f54129", "tiempo_minutos": 2,
      "destino": "POL. NORTE", "distancia_metros": 420, "sentido": 1 }
  ]
}
respuesta 200 · origen no disponible
{
  "parada": "43",
  "nombre": "Recaredo (Puerta Carmona)",
  "latitud": 37.389663,
  "longitud": -5.984265,
  "tiempos": [],
  "upstream_status": "unavailable",
  "upstream_detail": "TUSSAM API no disponible. Inténtalo en unos segundos."
}

Campos de cada tiempo

CampoTipoDescripción
lineastringNúmero de línea (ej: 01, C4)
colorstringColor hex oficial de la línea
tiempo_minutosintMinutos hasta la llegada. Puede ser negativo (bus ya en la parada)
destinostringNombre del destino final
distancia_metrosint / nullDistancia del bus a la parada
sentidoint / null1 (ida), 2 (vuelta), o null si la línea pasa en ambos sentidos
GET /paradas/{codigo}/lineas Público

Lista de números de línea que pasan por una parada. Ejemplo de respuesta: ["01", "C4"].

GET /lineas Público

Catálogo de líneas de TUSSAM con su color oficial, número, nombre y horarios de ida y vuelta.

respuesta 200 (un elemento)
{
  "numero": "01",
  "nombre": "Pol. Norte - H. Virgen del Rocío",
  "color": "#f54129",
  "sublinea": null,
  "hora_inicio_ida": "06:00",
  "hora_fin_ida": "23:30",
  "hora_inicio_vuelta": "06:00",
  "hora_fin_vuelta": "23:30",
  "updated_at": "2026-02-16T20:41:18"
}
GET /lineas/{linea_numero}/paradas Público

Recorrido de una línea: sus paradas ordenadas por sentido y orden. El número de línea es alfanumérico (1–8 caracteres); un formato inválido devuelve 422. Cada parada incluye los campos habituales más sentido y orden.

GET /health Público

Estado de la API y de la base de datos para Docker, balanceadores y monitorización. No consulta el origen ni consume cuota de rate limiting.

respuesta 200
{ "status": "ok", "db": "connected", "paradas_en_db": 967, "version": "2.0.0" }
  • status: "ok" — operativo con datos.
  • status: "no_data" — la base de datos responde pero está vacía (falta el sync inicial).
  • 503 — la base de datos no responde.
POST /sync/* API key

Endpoints de sincronización que reconstruyen el catálogo desde el origen. Requieren X-API-Key. Solo se ejecuta una sincronización a la vez: si hay una en curso, el resto responde 409. El scheduler los ejecuta automáticamente una vez por semana.

EndpointAcción
POST /sync/allParadas + líneas + relaciones, en orden.
POST /sync/paradasSolo el catálogo de paradas.
POST /sync/lineasSolo el catálogo de líneas.
POST /sync/paradas-lineasSolo las relaciones parada-línea.
POST /sync/direccionesGeocodifica paradas sin dirección con Nominatim (~4 min).
Un sync que no recupera datos del origen (por ejemplo, bloqueo de Cloudflare) aborta sin machacar el catálogo existente: se preserva el último estado bueno en lugar de dejar la base de datos vacía o parcial.

Códigos de error

CódigoSignificado
400Parámetro de negocio inválido (lat/lon/bearing/formato/sentido fuera de rango).
403API key inválida o ausente en un endpoint de sync.
404Parada inexistente (con formato de código válido).
409Ya hay una sincronización en curso.
422Validación de formato en el borde (código no numérico, radio o max_paradas fuera de rango).
429Rate limit superado. Consulta Retry-After.
500Error interno inesperado.
503Base de datos no disponible, o configuración de sync insegura.

Despliegue

La vía más rápida es la imagen publicada en el GitHub Container Registry (ghcr.io/686f6c61/api-tussam), que incluye una base de datos precargada: la API responde desde el primer arranque sin necesidad de sincronizar. La clave de sync solo hace falta para los endpoints de administración; expórtala en tu entorno (por ejemplo, generada con openssl rand -hex 24).

imagen publicada en GHCR
# Descarga la imagen del GitHub Container Registry
docker pull ghcr.io/686f6c61/api-tussam:main

# Arráncala (escucha en el puerto 8081; la clave solo hace falta para /sync/*)
export MI_CLAVE_DE_SYNC=$(openssl rand -hex 24)
docker run -d -p 8081:8080 \
  -e SYNC_API_KEY=$MI_CLAVE_DE_SYNC \
  ghcr.io/686f6c61/api-tussam:main

# Verifica el estado
curl http://localhost:8081/health
alternativa: Docker Compose (build local)
export MI_CLAVE_DE_SYNC=$(openssl rand -hex 24)
SYNC_API_KEY=$MI_CLAVE_DE_SYNC docker compose up -d
sin Docker
pip install -e .
export MI_CLAVE_DE_SYNC=$(openssl rand -hex 24)
SYNC_API_KEY=$MI_CLAVE_DE_SYNC uvicorn app.main:app \
  --host 0.0.0.0 --port 8080 --proxy-headers

Variables de entorno

VariableDef.Descripción
APP_ENVdevelopmentproduction desactiva /docs, activa HSTS y bloquea sync inseguro.
SYNC_API_KEYClave para los endpoints de sincronización. Obligatoria para operarlos.
ENABLE_DOCSsegún entornoExpone /docs, /redoc y /openapi.json.
CORS_ORIGINS* (dev)Lista blanca de orígenes permitidos (separados por comas).
ALLOWED_HOSTSHosts permitidos (Host header). localhost se añade siempre.
TRUSTED_PROXY_IPSIPs de proxy de las que se acepta X-Forwarded-For.
TIEMPOS_CACHE_TTL_SECONDS60TTL del cache fresco de tiempos.
TIEMPOS_STALE_TTL_SECONDS600Antigüedad máxima de la cache stale de respaldo.
SYNC_MIN_COMPLETENESS_RATIO0.8Proporción mínima del catálogo que un sync debe recuperar para reemplazarlo. Evita mutilar la red al sincronizar en franjas de baja actividad.
SYNC_ENABLED / SYNC_DAY / SYNC_HOUR / SYNC_MINUTEtrue / sun / 11 / 0Programación del sync semanal (hora en UTC; mediodía por defecto, con la red en servicio).