🔌 Backend & APIs

Diseño de APIs REST en 2026: buenas prácticas que de verdad importan

Repaso práctico, con ejemplos reales, de las decisiones de diseño REST que sí importan en 2026: versionado, códigos de estado, paginación, idempotencia, errores consistentes y el mito de HATEOAS.

📅 18 de febrero de 2026 ⏱️ 10 min de lectura ✍️ Equipo ProgramacionWebs

REST lleva más de veinte años como el estilo arquitectónico por defecto para exponer datos por HTTP, y sigue siendo la opción razonable para la inmensa mayoría de APIs que se construyen hoy. Lo que ha cambiado en 2026 no es el estilo en sí, sino que buena parte de lo que antes era “depende del equipo” ahora tiene un estándar publicado, una implementación de referencia, o ambas cosas. Este artículo repasa las decisiones de diseño que de verdad se notan cuando alguien más tiene que integrar tu API: versionado, códigos de estado, paginación, idempotencia, errores y el eterno debate sobre HATEOAS.

Si vienes de comparar REST con otros estilos, este artículo es complementario a GraphQL vs REST vs tRPC: cuándo usar cada uno, donde se entra en el trade-off de elegir el estilo. Aquí se asume que ya has elegido REST y quieres hacerlo bien.

Versionado: pragmatismo antes que pureza

Hay tres formas habituales de versionar una API REST: en la URL (/v1/orders), en una cabecera personalizada (X-API-Version: 2026-01-15) o mediante negociación de contenido con Accept (Accept: application/vnd.miapi.v2+json). Los puristas de REST defienden la tercera porque respeta que la URL identifica un recurso, no una versión de la representación. En la práctica, casi ninguna API pública relevante lo hace así.

El versionado por prefijo en la URL (/v1/, /v2/) sigue siendo la opción más pragmática para la mayoría de equipos: es visible en los logs, fácil de enrutar en el balanceador o en el API gateway, y no exige que el cliente configure cabeceras especiales para algo tan básico como elegir versión. El precio que se paga es cierta impureza conceptual, que en la práctica a nadie le importa.

Lo que sí marca la diferencia entre una API bien versionada y una que da sustos es la disciplina alrededor de la deprecación:

  • Anuncia la deprecación con cabeceras estándar Deprecation y Sunset (RFC 8594) en cuanto una versión entre en modo de solo mantenimiento, no cuando ya esté a punto de apagarse.
  • Da un margen mínimo razonable —un trimestre suele ser el estándar de facto— antes de retirar una versión, y comunícalo también en la documentación, no solo en una cabecera que nadie lee.
  • Nunca cambies el comportamiento de una versión ya publicada. Si necesitas cambiar algo que rompe compatibilidad, es una versión nueva, no un parche silencioso a la vieja.

Códigos de estado: la parte que casi nadie hace bien del todo

El error más común no es usar un código de estado incorrecto, sino usar siempre los mismos tres o cuatro (200, 400, 404, 500) para todo, perdiendo la información que el protocolo HTTP ya te da gratis:

CódigoCuándo usarloError típico
201 CreatedAl crear un recurso con éxito, con Location apuntando al nuevo recursoDevolver 200 en un POST que crea algo
202 AcceptedPetición aceptada para proceso asíncrono, aún no completadoBloquear la respuesta hasta que termine un job largo
204 No ContentÉxito sin cuerpo de respuesta (por ejemplo, un DELETE)Devolver 200 con un body vacío {}
409 ConflictEl estado actual del recurso impide la operación (edición concurrente, duplicado)Devolver 400 genérico para todo lo que no es sintaxis
422 Unprocessable ContentSintaxis correcta pero semántica inválida (validación de negocio)Confundirlo sistemáticamente con 400
429 Too Many RequestsSe ha superado un límite de tasaDevolver 403 cuando en realidad es un límite temporal

La regla práctica: los códigos 4xx describen un error del cliente que puede corregir cambiando la petición; los 5xx describen un fallo del servidor que el cliente no puede arreglar reintentando igual. Mezclar ambos mundos —devolver 500 para un body mal formado, o 400 para un fallo interno de base de datos— rompe la lógica de reintentos automáticos que cualquier cliente HTTP razonable implementa.

Paginación: por qué el offset se rompe a escala

?page=3&size=20 es la forma más intuitiva de paginar y funciona perfectamente hasta que la tabla crece. El problema del offset no es solo de rendimiento —aunque escanear y descartar un millón de filas para llegar a la página 50.000 es real y doloroso—, es de consistencia: si se inserta o borra una fila entre dos peticiones de páginas consecutivas, el cliente puede ver un registro duplicado o perderse uno entero.

La paginación por cursor resuelve ambos problemas codificando en el propio cursor la posición exacta desde la que continuar, normalmente derivada de una columna con orden estable (un id autoincremental o un timestamp con desempate):

{
  "data": [
    { "id": "ord_9f21", "total": 4599 },
    { "id": "ord_9f20", "total": 1200 }
  ],
  "pageInfo": {
    "nextCursor": "eyJpZCI6Im9yZF85ZjIwIn0=",
    "hasMore": true
  }
}

Reglas que evitan los problemas más comunes en producción:

  • El servidor decide y limita el tamaño máximo de página; nunca confíes en un size arbitrario que llegue del cliente sin acotar.
  • El cursor debe ser opaco para el cliente (una cadena codificada, no un número plano que invite a manipularlo) y debe seguir funcionando aunque cambies la implementación interna.
  • Cuando no hay más resultados, nextCursor es null, no una cadena vacía ni un cursor que apunta a una página vacía.

Para listados internos pequeños y estables (un panel de administración con cientos de filas, no millones), el offset sigue siendo válido: no toda API necesita cursor desde el primer día.

Idempotencia: la garantía que evita cobros duplicados

Una operación es idempotente cuando ejecutarla una vez o veinte veces produce el mismo resultado final. GET, PUT y DELETE son idempotentes por definición del propio HTTP; POST no lo es, y ahí está el problema real: un cliente móvil con mala cobertura que reintenta una petición de pago tras un timeout puede acabar creando dos cargos si el servidor no tiene forma de reconocer que es la misma operación repetida.

El patrón que ha ganado desde que Stripe lo popularizó es la cabecera Idempotency-Key: el cliente genera un identificador único (normalmente un UUID) antes de enviar la petición, y lo reenvía igual en cada reintento de esa misma operación lógica.

POST /v1/charges HTTP/1.1
Idempotency-Key: 6ac5c39b-9b34-4f8a-8e2d-6f6d2f9d1a11
Content-Type: application/json

{ "amount": 4599, "currency": "eur", "customer": "cus_82hd" }

El servidor guarda, durante una ventana de tiempo razonable (24 horas es habitual), la respuesta asociada a cada clave de idempotencia. Si llega una petición repetida con la misma clave y el mismo cuerpo, devuelve la respuesta original sin repetir el efecto secundario. Si llega la misma clave con un cuerpo distinto, es un error del cliente —normalmente se responde 422— porque está reutilizando una clave para una operación diferente.

Errores consistentes: adopta RFC 9457 en vez de inventar tu propio formato

Durante años, cada API definía su propio formato de error: unas devolvían { "error": "mensaje" }, otras { "code": 4, "msg": "..." }, otras anidaban tres niveles de objetos. RFC 9457 (que sustituye a RFC 7807) estandariza un formato de “detalles del problema” pensado explícitamente para que no haga falta reinventarlo:

{
  "type": "https://api.miempresa.com/errors/insufficient-funds",
  "title": "Fondos insuficientes",
  "status": 422,
  "detail": "La cuenta cus_82hd tiene 12.40 € disponibles, se solicitaron 45.99 €",
  "instance": "/v1/charges/ch_3k2n"
}

Se sirve con el tipo de contenido application/problem+json, y el campo status debe coincidir siempre con el código HTTP real de la respuesta, precisamente para que un cliente HTTP genérico que no entienda este formato siga comportándose correctamente basándose solo en el código de estado. Puedes extender el objeto con campos propios (errors con una lista de validaciones de campo, por ejemplo) sin romper el estándar, porque el formato está pensado para ser extensible.

Adoptarlo no es solo una cuestión de estética: da a los clientes un contrato predecible para mostrar errores al usuario, distinguir errores de validación de errores de negocio, y enlazar a documentación específica del error mediante el campo type.

HATEOAS: la parte de REST que casi nadie implementa como el libro dice

HATEOAS (Hypermedia as the Engine of Application State) es, en la definición original de Roy Fielding, uno de los requisitos para que una API sea “verdaderamente RESTful”: las respuestas deben incluir enlaces que indiquen qué acciones son válidas desde el estado actual, de modo que el cliente navegue la API como un usuario navega la web, sin hardcodear URLs.

La honestidad que merece este apartado: casi nadie lo implementa así en producción, y no es por ignorancia. Los motivos son estructurales, no un fallo de disciplina de la industria:

  • Los frontends modernos se despliegan junto a la API que consumen y están fuertemente acoplados a ella por diseño (mismo equipo, mismo repositorio o monorepo, mismo ciclo de release). No son clientes genéricos que necesiten descubrir la API en tiempo de ejecución.
  • Aunque un cliente reciba un enlace POST /orders/123/cancel la primera vez, en cuanto lo usa una vez lo memoriza y lo cachea en su propio código. Eso no es un cliente hipermedia real, es un cliente que hardcodea URLs con un paso intermedio.
  • OpenAPI, los clientes generados a partir de un esquema tipado y una buena documentación resuelven el mismo problema de descubribilidad de forma más práctica para el 95% de los casos reales.

Donde sí aporta valor en 2026 es en un patrón más modesto que el HATEOAS de manual: incluir un campo available_actions (o _links al estilo HAL) que comunique qué transiciones de estado son válidas ahora mismo, sin pretender que el cliente descubra toda la API navegando desde la raíz:

{
  "id": "ord_9f21",
  "status": "pending",
  "total": 4599,
  "actions": {
    "cancel": { "method": "POST", "href": "/v1/orders/ord_9f21/cancel" },
    "pay": { "method": "POST", "href": "/v1/orders/ord_9f21/pay" }
  }
}

Esto es explícito, tipable, y comunica reglas de negocio dinámicas (un pedido ya pagado no debería exponer cancel) sin exigir un cliente hipermedia genérico que en la práctica nadie construye.

graph LR
A[Cliente hace GET /orders/123] --> B{status del pedido}
B -->|pending| C["actions: cancel, pay"]
B -->|paid| D["actions: refund"]
B -->|cancelled| E["actions: (ninguna)"]

Un contrato consistente importa más que seguir la letra de REST

Si hay una conclusión práctica en todo esto, es que ninguna de estas decisiones vale nada por separado; lo que hace que una API sea agradable de integrar es la consistencia entre todas ellas: el mismo formato de error en todos los endpoints, la misma convención de paginación en todos los listados, los mismos códigos de estado para los mismos tipos de situación. Un equipo que documenta bien estas reglas en un OpenAPI compartido —y las hace cumplir con tests de contrato— gana más en productividad de integración que cualquier discusión sobre si un endpoint concreto es “más o menos RESTful”.

Para quien construye APIs desde cero en 2026, la recomendación es concreta: usa RFC 9457 para errores desde el primer día, cursor para cualquier listado que pueda crecer sin límite, Idempotency-Key en las operaciones con efectos secundarios reales, versiona por URL si no tienes una razón de peso para otra cosa, y deja HATEOAS “puro” en los libros de texto salvo que construyas específicamente una API pensada para ser descubierta por clientes genéricos que no controlas.

Compartir