Por qué importan los códigos de estado y un contrato de respuesta
En una API, el código de estado HTTP comunica el resultado a nivel de protocolo; el cuerpo y las cabeceras completan el “contrato” que el cliente usa para reaccionar (reintentar, cachear, mostrar errores, seguir enlaces, etc.). Un contrato consistente reduce lógica condicional en clientes, facilita observabilidad y evita ambigüedades (por ejemplo, “200 con un error dentro del JSON”).
Principios de consistencia
- El código de estado debe reflejar el resultado real: éxito, redirección/caché, error del cliente o del servidor.
- Formato de error uniforme para todos los 4xx/5xx (misma estructura, mismos campos base).
- Cabeceras coherentes:
Content-Typesiempre que haya cuerpo;Locational crear;ETagpara concurrencia/caché;Retry-Aftercuando aplique. - No inventar semántica: evitar “200 OK” con
{"success": false}.
Mapa práctico: escenarios comunes → código → cuerpo/cabeceras
Respuestas de éxito (2xx)
| Escenario | Código | Cuerpo | Cabeceras clave |
|---|---|---|---|
| Lectura exitosa (GET) | 200 OK | Representación del recurso o colección | Content-Type, opcional ETag, Cache-Control |
| Creación sincrónica (POST crea recurso) | 201 Created | Recomendado: representación del recurso creado | Location (URL del nuevo recurso), Content-Type, opcional ETag |
| Procesamiento asíncrono aceptado | 202 Accepted | Opcional: estado/recibo con enlace a recurso de operación | Location (URL de estado), opcional Retry-After |
| Éxito sin contenido (DELETE o PUT sin representación) | 204 No Content | Sin cuerpo | Evitar Content-Type; puede incluir ETag si aplica a la respuesta (raro) |
Regla clave: si respondes 204, no envíes cuerpo (ni siquiera {}). Si necesitas devolver datos (por ejemplo, el recurso actualizado), usa 200.
Caché y validación (304)
| Escenario | Código | Cuerpo | Cabeceras clave |
|---|---|---|---|
| GET condicional: el recurso no cambió | 304 Not Modified | Sin cuerpo | ETag y/o Last-Modified (si se usa), Cache-Control |
Para habilitar 304, el servidor debe emitir un validador (típicamente ETag) en respuestas 200. El cliente envía If-None-Match y el servidor responde 304 si no hay cambios.
Errores del cliente (4xx)
| Escenario | Código | Cuándo usarlo | Cuerpo recomendado | Cabeceras clave |
|---|---|---|---|---|
| Solicitud mal formada | 400 Bad Request | JSON inválido, parámetros imposibles, tipos incorrectos | Error estándar + detalles de parsing/param | Content-Type |
| No autenticado | 401 Unauthorized | Falta token/credenciales o son inválidas | Error estándar | WWW-Authenticate (si aplica), Content-Type |
| Autenticado pero sin permisos | 403 Forbidden | El usuario no puede acceder/operar | Error estándar | Content-Type |
| No existe el recurso | 404 Not Found | ID inexistente o ruta no encontrada | Error estándar | Content-Type |
| Método no permitido | 405 Method Not Allowed | Ruta existe pero verbo no soportado | Error estándar | Allow (lista de métodos), Content-Type |
| Conflicto de estado | 409 Conflict | Duplicados, violación de unicidad, conflicto de concurrencia (según convención) | Error estándar + causa | Content-Type, opcional ETag |
| Recurso ya no disponible | 410 Gone | Existió pero fue retirado permanentemente | Error estándar | Content-Type |
| Media type no soportado | 415 Unsupported Media Type | Content-Type enviado no soportado | Error estándar + lista soportada | Accept (opcional), Content-Type |
| Entidad válida sintácticamente pero inválida semánticamente | 422 Unprocessable Entity | Validación de negocio/campos (formato correcto, reglas fallan) | Error estándar + errores por campo | Content-Type |
| Demasiadas solicitudes | 429 Too Many Requests | Rate limit excedido | Error estándar + info de límite | Retry-After, opcional X-RateLimit-* |
400 vs 422: usa 400 cuando la solicitud no se puede interpretar correctamente (sintaxis/estructura). Usa 422 cuando se entiende, pero no pasa validaciones (por ejemplo, email con formato válido pero ya registrado, o startDate > endDate).
Errores del servidor y upstream (5xx)
| Escenario | Código | Cuándo usarlo | Cuerpo recomendado | Cabeceras clave |
|---|---|---|---|---|
| Error inesperado | 500 Internal Server Error | Excepción no controlada, bug | Error estándar (sin filtrar detalles sensibles) | Content-Type, opcional Retry-After si es transitorio |
| Fallo en dependencia/upstream | 502 Bad Gateway | Gateway/proxy recibe respuesta inválida de upstream | Error estándar + referencia | Content-Type |
| Servicio temporalmente no disponible | 503 Service Unavailable | Mantenimiento, saturación, degradación | Error estándar | Retry-After recomendado, Content-Type |
Convenciones recomendadas para el cuerpo
Contrato de éxito
Define una convención estable por tipo de respuesta:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
- Recurso individual: el JSON del recurso.
- Colección:
{"items": [...], "page": {...}}(si hay paginación) o un array directo si tu estándar lo permite, pero sé consistente. - 202 (asíncrono): un “operation resource” con estado.
Contrato de error uniforme (4xx/5xx)
Usa un objeto de error consistente. Ejemplo de estructura mínima:
{ "error": { "code": "validation_failed", "message": "One or more fields are invalid.", "details": [ {"field": "email", "issue": "invalid_format"}, {"field": "age", "issue": "must_be_greater_or_equal", "min": 18} ], "requestId": "c0a8012e-7b1d-4f2a-9b1a-2b2c3d4e5f6a" }}code: identificador estable para lógica del cliente (no dependas solo demessage).message: legible para humanos.details: lista opcional para validación/diagnóstico.requestId: correlación con logs/tracing (también puede ir en cabecera).
Cabeceras: convenciones y uso correcto
Content-Type
- En respuestas con cuerpo JSON:
Content-Type: application/json; charset=utf-8. - En
204y304: normalmente no enviarContent-Typeporque no hay cuerpo.
Accept
- El cliente envía
Acceptpara indicar formatos aceptados. - Si no puedes responder con un formato aceptable, lo correcto es
406 Not Acceptable(no está en la lista requerida, pero es útil conocerlo). Si tu API no negocia contenido, documenta que solo se soporta JSON y validaAcceptde forma pragmática.
Location
- En
201 Created: URL del recurso creado. - En
202 Accepted: URL donde consultar el estado de la operación (o el recurso resultante si ya existe pero está procesándose).
ETag
- Servidor devuelve
ETagen200(y a menudo en201) para identificar versión. - Cliente puede usar
If-None-Match(caché) yIf-Match(concurrencia optimista). Si usasIf-Matchy falla, el código típico es412 Precondition Failed(no incluido en la lista, pero relevante si adoptas ETags para escritura).
Cache-Control
- Define cacheabilidad explícita:
Cache-Control: private, max-age=60ono-storepara datos sensibles. - Combina con
ETagpara GET condicional eficiente.
Retry-After
- En
429y503(y a veces202): indica cuándo reintentar. - Puede ser segundos o fecha HTTP:
Retry-After: 120.
Tablas de decisión (cheat sheets)
Decisión para operaciones de escritura (POST/PUT/PATCH/DELETE)
| Pregunta | Si | No |
|---|---|---|
| ¿La solicitud es sintácticamente inválida (JSON roto, tipos imposibles)? | 400 + error | Siguiente |
| ¿Falta autenticación o es inválida? | 401 + error (+ WWW-Authenticate) | Siguiente |
| ¿Está autenticado pero no autorizado? | 403 + error | Siguiente |
| ¿El recurso objetivo no existe? | 404 + error | Siguiente |
| ¿El método no está permitido en esa ruta? | 405 + error (+ Allow) | Siguiente |
¿El Content-Type no es soportado? | 415 + error | Siguiente |
| ¿Falla validación semántica/reglas de negocio? | 422 + error con details | Siguiente |
| ¿Hay conflicto (duplicado/estado incompatible)? | 409 + error | Siguiente |
| ¿Se crea un recurso nuevo sincrónicamente? | 201 + Location (+ representación) | Siguiente |
| ¿Se procesa asíncronamente? | 202 + Location (+ estado) (+ Retry-After) | Siguiente |
| ¿La operación tuvo éxito pero no hay contenido que devolver? | 204 sin cuerpo | 200 + representación |
Decisión para GET con caché
| Condición | Respuesta | Notas |
|---|---|---|
| Recurso existe y el cliente no envía validadores | 200 + cuerpo | Incluye ETag y Cache-Control si aplica |
Cliente envía If-None-Match y no cambió | 304 sin cuerpo | Mantén cabeceras de caché/validación |
Cliente envía If-None-Match y cambió | 200 + cuerpo | Nuevo ETag |
Ejemplos de respuestas completas
200 OK (GET) con ETag y caché
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Cache-Control: private, max-age=60ETag: "v3-9f2c1a"{ "id": "u_123", "name": "Ada Lovelace", "email": "ada@example.com"}304 Not Modified (GET condicional)
HTTP/1.1 304 Not ModifiedCache-Control: private, max-age=60ETag: "v3-9f2c1a"201 Created (POST) con Location y representación
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8Location: https://api.example.com/users/u_124ETag: "v1-1a2b3c"{ "id": "u_124", "name": "Grace Hopper", "email": "grace@example.com"}202 Accepted (proceso asíncrono) con Location y Retry-After
HTTP/1.1 202 AcceptedContent-Type: application/json; charset=utf-8Location: https://api.example.com/operations/op_789Retry-After: 10{ "operationId": "op_789", "status": "pending", "submittedAt": "2026-02-03T10:15:30Z"}204 No Content (DELETE)
HTTP/1.1 204 No Content400 Bad Request (JSON inválido)
HTTP/1.1 400 Bad RequestContent-Type: application/json; charset=utf-8{ "error": { "code": "invalid_json", "message": "Request body is not valid JSON.", "requestId": "b7b2a1d0-2c3d-4e5f-9a10-11c12d13e14f" }}401 Unauthorized (sin credenciales)
HTTP/1.1 401 UnauthorizedContent-Type: application/json; charset=utf-8WWW-Authenticate: Bearer realm="api", error="invalid_token"{ "error": { "code": "unauthenticated", "message": "Authentication is required.", "requestId": "2f1c0d9e-8b7a-6c5d-4e3f-2a1b0c9d8e7f" }}403 Forbidden (sin permisos)
HTTP/1.1 403 ForbiddenContent-Type: application/json; charset=utf-8{ "error": { "code": "forbidden", "message": "You do not have permission to perform this action.", "requestId": "f1e2d3c4-b5a6-7980-1234-56789abcdeff" }}404 Not Found
HTTP/1.1 404 Not FoundContent-Type: application/json; charset=utf-8{ "error": { "code": "not_found", "message": "User 'u_999' was not found.", "requestId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9" }}405 Method Not Allowed (con Allow)
HTTP/1.1 405 Method Not AllowedContent-Type: application/json; charset=utf-8Allow: GET, POST{ "error": { "code": "method_not_allowed", "message": "Method PUT is not allowed for this endpoint.", "requestId": "aa11bb22-cc33-dd44-ee55-ff6677889900" }}409 Conflict (duplicado)
HTTP/1.1 409 ConflictContent-Type: application/json; charset=utf-8{ "error": { "code": "conflict", "message": "Email is already in use.", "details": [ {"field": "email", "issue": "already_exists"} ], "requestId": "13579bdf-2468-ace0-1357-9bdf2468ace0" }}410 Gone (recurso retirado)
HTTP/1.1 410 GoneContent-Type: application/json; charset=utf-8{ "error": { "code": "gone", "message": "This resource has been permanently removed.", "requestId": "01234567-89ab-cdef-0123-456789abcdef" }}415 Unsupported Media Type
HTTP/1.1 415 Unsupported Media TypeContent-Type: application/json; charset=utf-8{ "error": { "code": "unsupported_media_type", "message": "Content-Type 'text/plain' is not supported. Use 'application/json'.", "requestId": "deafbeef-dead-beef-dead-beefdeadbeef" }}422 Unprocessable Entity (validación)
HTTP/1.1 422 Unprocessable EntityContent-Type: application/json; charset=utf-8{ "error": { "code": "validation_failed", "message": "One or more fields are invalid.", "details": [ {"field": "email", "issue": "invalid_format"}, {"field": "password", "issue": "too_short", "minLength": 12} ], "requestId": "11111111-2222-3333-4444-555555555555" }}429 Too Many Requests (rate limit) con Retry-After
HTTP/1.1 429 Too Many RequestsContent-Type: application/json; charset=utf-8Retry-After: 60{ "error": { "code": "rate_limited", "message": "Too many requests. Please retry later.", "details": [ {"limit": 100, "windowSeconds": 60} ], "requestId": "99999999-8888-7777-6666-555555555555" }}500 Internal Server Error
HTTP/1.1 500 Internal Server ErrorContent-Type: application/json; charset=utf-8{ "error": { "code": "internal_error", "message": "An unexpected error occurred.", "requestId": "abcdabcd-abcd-abcd-abcd-abcdabcdabcd" }}502 Bad Gateway
HTTP/1.1 502 Bad GatewayContent-Type: application/json; charset=utf-8{ "error": { "code": "bad_gateway", "message": "Upstream service returned an invalid response.", "requestId": "123e4567-e89b-12d3-a456-426614174000" }}503 Service Unavailable con Retry-After
HTTP/1.1 503 Service UnavailableContent-Type: application/json; charset=utf-8Retry-After: 120{ "error": { "code": "service_unavailable", "message": "Service is temporarily unavailable. Please retry later.", "requestId": "fedcba98-7654-3210-fedc-ba9876543210" }}Guía paso a paso para definir tu contrato de respuestas
Paso 1: Define el “perfil” de respuesta por categoría
- Éxito con cuerpo:
200/201/202→ JSON +Content-Type. - Éxito sin cuerpo:
204y304→ sin cuerpo. - Error: 4xx/5xx → JSON de error uniforme +
Content-Type.
Paso 2: Establece el objeto de error estándar
Documenta campos obligatorios (por ejemplo error.code, error.message, requestId) y opcionales (details). Asegura que todos los endpoints lo devuelvan igual.
Paso 3: Mapea escenarios a códigos (tabla de decisión)
Adopta una tabla como las anteriores y úsala como checklist en revisiones de API. Esto evita inconsistencias entre equipos.
Paso 4: Define cabeceras obligatorias por respuesta
201:Locationobligatorio.405:Allowobligatorio.429/503:Retry-Afterrecomendado.- Respuestas cacheables:
Cache-ControlyETagrecomendados.
Paso 5: Valida automáticamente
Implementa tests contractuales que verifiquen: (1) código correcto por escenario, (2) ausencia de cuerpo en 204/304, (3) presencia de cabeceras requeridas, (4) forma del error uniforme en 4xx/5xx.