Las APIs no se rompen de golpe, se degradan. Empiezan limpias, con tres endpoints y un esquema que cabe en una servilleta, y seis meses después arrastran campos que nadie recuerda haber añadido, códigos de estado que mienten, paginación inconsistente entre recursos y un changelog que nadie mantiene. Diseñar una API que no odiemos en seis meses no es cuestión de elegancia, es cuestión de tomar decisiones aburridas al principio y sostenerlas con disciplina.
Versionado explícito desde el día uno. Tenemos dos opciones serias: versión en la ruta (/v1/orders) o versión en el header Accept (application/vnd.kimox.v1+json). La ruta gana en claridad operativa, es visible en logs, en métricas, en cualquier traza de Sentry, y los clientes la entienden sin documentación. El header gana cuando el contrato cambia para el mismo recurso según el consumidor, típicamente en plataformas con múltiples SDKs oficiales y un ciclo de vida largo. Para el 95% de los casos, /v1 en la ruta es la respuesta correcta. Lo importante no es cuál, es comprometerse antes de tener un solo cliente en producción.
Paginación: cursor por defecto, offset solo cuando hace falta. El offset es cómodo para listados administrativos pequeños, pero falla con datasets grandes, escrituras concurrentes y cualquier orden que no sea estrictamente estable. El cursor opaco basado en un campo monotónico (timestamp más id) es resistente a inserciones, soporta navegación infinita en feeds y no obliga a contar registros. Página por defecto de 25, máximo 100, y un next_cursor explícito en la respuesta. Nunca devolvemos el total cuando es caro calcularlo; preferimos un has_more honesto.
Errores que no mienten. Adoptamos RFC 7807 (application/problem+json) con type, title, status, detail y un instance correlacionable con nuestros logs. Un 422 es un 422, un 409 es un 409, un 404 no es nunca un 200 con {"error": "not found"}. Devolver 200 con un cuerpo de error es la decisión que destruye más clientes: rompe retries, rompe circuit breakers, rompe métricas de éxito. Los códigos HTTP existen, los usamos. Para errores de validación incluimos un array errors con pointer JSON Pointer al campo afectado.
Idempotencia para todo POST que muta dinero o estado externo. Aceptamos Idempotency-Key como header obligatorio en creación de pagos, envíos de email transaccional, llamadas a sistemas externos. Guardamos la respuesta original durante 24 horas indexada por esa clave y la devolvemos tal cual ante reintentos. Esto no es opcional para webhooks entrantes: la red falla, los proveedores reintentan, y sin idempotencia cobramos dos veces. La clave la genera el cliente, normalmente un UUIDv4 por intento lógico.
Nombres y recursos sin barroquismo. Sustantivos en plural (/invoices, /customers), nunca verbos en la ruta. Anidamiento máximo dos niveles: /customers/{id}/invoices está bien, /customers/{id}/invoices/{id}/lines/{id}/taxes no lo está. Cuando algo no encaja en CRUD, lo modelamos como subrecurso (/orders/{id}/cancellation con POST) en vez de inventar /cancelOrder. Filtros, ordenación y búsqueda van en query string, no en la ruta: ?status=paid&sort=-created_at.
Evolución aditiva y nada más. Añadir campos opcionales y endpoints nuevos no rompe a nadie. Renombrar, eliminar o cambiar el tipo de un campo sí. Cuando necesitamos cambiar, aplicamos expand-then-contract: añadimos el campo nuevo, migramos clientes, y solo después retiramos el viejo, con un header Deprecation: true y Sunset con fecha ISO. Las ventanas de deprecación nunca son inferiores a seis meses para clientes externos.
La documentación es código. Un OpenAPI generado a partir de los handlers, ejemplos ejecutables, y un changelog versionado en el mismo repositorio. Si la documentación vive en otro sitio, miente.