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.

json
// ✅ 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.

json
// 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 errors para detalle de validación a nivel de campo — no estás haciendo un workaround.
  • Soporte de herramientas: OpenAPI 3.x soporta application/problem+json como tipo de contenido de respuesta, así que tus docs generadas y SDKs de cliente entienden la forma de forma nativa.
  • El URI type es 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").

json
// 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 Location apuntando 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.

json
// ✅ 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".

json
// 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 it

Paginació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.

json
// 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 === false

Nombres 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.

Consejo de diseño: Cuando estás diseñando o depurando una respuesta real de API, pega el JSON en el JSON Formatter — lo formateará bonito, resaltará la estructura, y te dejará detectar inconsistencias de nombres de un vistazo antes de que lleguen a un SDK de cliente.

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.