Cada equipo con el que he trabajado acaba inventando su propio formato de respuesta de API. Al principio parece inofensivo —
un pequeño objeto envoltorio aquí, una forma de error personalizada allí — y seis meses después estás escribiendo la cuarta
versión de tu middleware de parseo de errores y discutiendo en code review si data.user o
data.result.user es la ruta "correcta". No existe un estándar universal que resuelva todo esto, pero
hay patrones que aguantan en producción y antipatrones que absolutamente te volverán a morder.
Esto es lo que realmente pondría en un documento de diseño.
Respuestas de éxito consistentes
La primera pregunta que cada equipo debate: ¿debe cada respuesta envolverse en un sobre como
{"status": "ok", "data": {...}}? La respuesta honesta es — probablemente no por defecto. Los sobres
tenían más sentido a principios de los 2000 cuando los códigos de estado HTTP no siempre eran fiables a través de proxies y
redes móviles. Hoy, una respuesta plana que deja al recurso hablar por sí mismo es casi siempre más limpia.
Reserva el sobre para endpoints que devuelven genuinamente payloads mixtos, como una operación en masa que
triunfa parcialmente.
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
"id": "ord_9kZ2m",
"status": "fulfilled",
"customer_id": "cus_4xA1p",
"total_amount": 149.99,
"currency": "USD",
"created_at": "2026-03-15T11:42:00Z",
"line_items": [
{ "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
]
}
// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
"status": "success",
"code": 200,
"data": {
"order": {
"id": "ord_9kZ2m"
}
}
}Envolver tiene sentido cuando necesitas co-localizar metadatos que no son parte del recurso en sí —
cursores de paginación, IDs de request para tracing, o resúmenes de fallo parcial en endpoints masivos. Para un simple
GET /orders/:id, el pedido es la respuesta. No hagas que los clientes escriban
response.data.order.id cuando response.id funciona perfectamente. Si quieres una spec a la que
referirte, JSON:API es un estándar opinable
pero bien pensado que define exactamente cuándo y cómo usar sobres — vale la pena leerlo aunque
no lo adoptes al completo.
Respuestas de error — Usa RFC 7807 Problem Details
Las formas de error personalizadas son una de las fuentes más comunes de dolor de integración. Cada API acaba con
algo ligeramente diferente — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — y cada cliente que consume tu API tiene que escribir lógica de parseo de errores
a medida. La IETF resolvió esto con
RFC 7807 — Problem Details for HTTP APIs.
Es un estándar ligero que define una estructura JSON consistente para errores, con un
Content-Type de application/problem+json. Adóptalo y tu formato de error se convierte en
algo que cualquier desarrollador puede leer sin tener que ir a buscar la documentación.
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The order could not be created because one or more fields are invalid.",
"instance": "/v1/orders/requests/req_7bN3k",
"errors": [
{
"field": "line_items[0].quantity",
"message": "Quantity must be a positive integer."
},
{
"field": "shipping_address.postal_code",
"message": "Postal code is required for US shipments."
}
]
}- Parseo predecible: Los clientes siempre saben dónde encontrar el mensaje legible por humanos (
detail), la categoría legible por máquina (type), y el estado HTTP reflejado en el cuerpo (status). - Extensible por diseño: La spec permite explícitamente campos extra como
errorspara detalle de validación a nivel de campo — no estás haciendo un workaround. - Soporte de herramientas: OpenAPI 3.x soporta
application/problem+jsoncomo tipo de contenido de respuesta, así que tus docs generadas y SDKs de cliente entienden la forma de forma nativa. - El URI
typees un documento, no solo una cadena: Apúntalo a una página real explicando el error, y acabas de sustituir un ticket de soporte por una respuesta autoservicio.
Códigos de estado HTTP + cuerpo JSON juntos
El código de estado y el cuerpo JSON no son redundantes — juegan roles diferentes. El código de estado le dice a la capa HTTP (proxies, cachés, navegadores, herramientas de monitoring) qué pasó. El cuerpo JSON se lo dice a tu capa de aplicación. Ambos deben ser correctos. La referencia de estado HTTP de MDN es la forma más rápida de resolver debates sobre qué código encaja. Los que más despistan a los equipos son 400 vs 422 (ambos son errores de cliente, pero 422 significa específicamente que la sintaxis era válida y el servidor la entendió — la semántica estaba mal), y 401 vs 403 (401 significa "¿quién eres?", 403 significa "sé quién eres — no puedes hacer esto").
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
"type": "https://api.example.com/problems/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "Request body is not valid JSON."
}
// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
"type": "https://api.example.com/problems/insufficient-inventory",
"title": "Insufficient Inventory",
"status": 422,
"detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
"instance": "/v1/orders/requests/req_7bN3k"
}
// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
"type": "https://api.example.com/problems/not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order with ID ord_XXXXX exists in this account."
}- 200 OK — GET, PUT, PATCH exitoso que devuelve un cuerpo
- 201 Created — POST exitoso que creó un recurso; incluye un encabezado
Locationapuntando al nuevo recurso - 204 No Content — DELETE o acción exitosa sin cuerpo de respuesta; no hace falta JSON
- 400 Bad Request — sintaxis de petición malformada, el servidor no puede ni parsearla
- 401 Unauthorized — credenciales de autenticación ausentes o inválidas
- 403 Forbidden — autenticado pero no permitido
- 404 Not Found — el recurso no existe
- 409 Conflict — conflicto de estado (p.ej. pedido duplicado, fallo de lock optimista)
- 422 Unprocessable Entity — sintaxis válida, falló validación semántica/de negocio
- 429 Too Many Requests — límite de tasa alcanzado; incluye siempre un encabezado
Retry-After - 500 Internal Server Error — algo se rompió en el servidor; nunca filtres stack traces en el cuerpo
Fechas y horas — Siempre ISO 8601
Los timestamps Unix parecen limpios — solo un número. Pero son una trampa. ¿1710499200 son segundos
o milisegundos? (Ambos son comunes. Date.now() de JavaScript da milisegundos, POSIX da
segundos.) ¿Qué zona horaria? Son ilegibles en logs sin un convertidor. No pueden representar fechas antes de
1970 limpiamente. Y desbordarán enteros de 32 bits en 2038 en sistemas que no hayan migrado todavía.
ISO 8601 en cadenas resuelve
todo esto. Usa UTC e incluye siempre el offset de zona horaria — un 2026-03-15T11:42:00
pelado sin una Z o +00:00 al final es ambiguo y eventualmente causará un bug en
un cliente que asume hora local.
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
"created_at": "2026-03-15T11:42:00Z",
"updated_at": "2026-04-01T08:15:33Z",
"scheduled_delivery": "2026-03-18T00:00:00Z",
"expires_at": "2026-04-15T23:59:59Z"
}
// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
"created_at": 1710499200,
"updated_at": 1743494133000,
"scheduled_delivery": "15/03/2026",
"expires_at": "April 15, 2026"
}Null vs campos omitidos
Estos dos no son lo mismo y confundirlos crea bugs sutiles que solo afloran en casos extremos.
Null significa que el campo existe, el servidor lo conoce, y su valor actual es "nada" —
como un timestamp fulfilled_at en un pedido que aún no se ha enviado.
Omitir un campo por completo significa que no aplica en este contexto — como un
return_tracking_number en un pedido no devuelto. Si un cliente ve "fulfilled_at": null,
sabe que el campo es parte del esquema de este recurso y está explícitamente sin establecer. Si el campo está ausente, el
cliente debería tratarlo como fuera del alcance de esta respuesta — lo cual importa cuando estás haciendo actualizaciones
parciales con PATCH. Enviar null significa "limpia este campo"; omitirlo significa "no lo toques".
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
"id": "ord_9kZ2m",
"status": "processing",
"created_at": "2026-03-15T11:42:00Z",
"fulfilled_at": null,
"shipped_at": null,
"tracking_number": null,
"total_amount": 149.99
}
// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
"scheduled_delivery": null,
"status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching itPaginación — Cursor antes que offset
La paginación por offset (?page=3&per_page=20) es intuitiva de implementar y fácil de explicar,
pero se rompe silenciosamente con datos en vivo. Si se inserta un registro mientras un cliente está paginando — entre la página 2
y la página 3 — se saltará un elemento. Si se borra un registro, verá un duplicado. Para cualquier dataset que
cambie frecuentemente (pedidos, eventos, notificaciones), la paginación basada en cursor es el default correcto. Le das
al cliente un cursor opaco (típicamente un ID o timestamp codificado en base64) que representa su posición en
el conjunto de resultados. La siguiente página empieza desde ese punto exacto, independientemente de inserciones o borrados. La paginación
por offset está bien para UIs de admin donde el dataset es estable y los usuarios realmente necesitan saltar a la página 47.
No está bien para ningún cliente móvil haciendo scroll infinito.
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
"orders": [
{ "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
{ "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
],
"pagination": {
"next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
"has_more": true,
"limit": 20
}
}
// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === falseNombres de campos — snake_case vs camelCase
Elige una convención y hazla cumplir con un linter. La elección en sí importa menos que la
consistencia. Dicho esto: si tus consumidores principales son clientes JavaScript/TypeScript,
camelCase se integra limpiamente con destructuring y spread de objetos.
Si tus consumidores principales son backends Python o Ruby, snake_case se siente natural.
Si sirves a ambos, la solución pragmática es documentar la convención y dejar que los clientes usen una
capa de transformación — JSON.parse
con un reviver, una biblioteca humps de Python, o una única config de serialización en tu framework.
Lo que nunca deberías hacer es mezclar convenciones en la misma API — customerId junto a
order_total es una señal de que diferentes ingenieros escribieron diferentes endpoints sin hablar entre sí.
Usa el Generador de JSON Schema para documentar tus nombres de campo
consistentemente entre endpoints.
Versionado
Dos escuelas: versionado por URL (/v1/orders, /v2/orders) y versionado por header
(Accept: application/vnd.example.v2+json o un encabezado personalizado API-Version: 2026-03-15).
El versionado por URL gana en la práctica casi siempre. Es visible en logs sin parsear headers,
funciona con cada cliente HTTP sin configuración, puedes probarlo en un navegador, y puedes correr v1
y v2 en paralelo en el mismo gateway con una simple regla de prefijo de ruta. El versionado por header es teóricamente
más RESTful según el modelo
media type de IANA,
pero crea complejidad invisible — una petición que se ve idéntica en la URL en realidad está
comportándose diferente dependiendo de un header que la mayoría de desarrolladores no revisan primero.
El versionado basado en fecha de Stripe (Stripe-Version: 2024-06-20) es lo mejor de ambos mundos para
plataformas grandes, pero ese es un problema distinto al de elegir tu primer esquema de versionado.
Elijas lo que elijas, versiona desde el día uno. Meter versionado a posteriori en una API sin versionar en producción
es doloroso y rara vez sale limpio. Usa el Validador JSON para confirmar que
las respuestas de ambas versiones de API están estructuralmente bien durante las pruebas de migración.
Para cerrar
Nada de esto es rompedor — pero ese es el punto. Los equipos que más luchan con el diseño de API no son los que tomaron decisiones técnicamente equivocadas. Son los que tomaron decisiones diferentes en diferentes endpoints y nunca las escribieron. Respuestas de éxito planas. Cuerpos de error RFC 7807. Fechas ISO 8601. Paginación por cursor en datos en vivo. Null para "conocido y vacío", omitido para "no aplica". Versionado por URL desde el día uno. Estos patrones no son perfectos, pero son predecibles — y la predictibilidad es lo que hace de una API un placer con el que integrar en vez de un puzzle para hacer ingeniería inversa. La especificación JSON formal vive en RFC 8259 si alguna vez necesitas zanjar un argumento a nivel de spec. Para todo lo que está por encima de esa capa, el mejor estándar es el que tu equipo realmente escribe y sigue consistentemente.