TunjaAPI

Referencia · v1

Cada endpoint, entero

Qué pide, qué devuelve, qué cambia si mandas la clave y cómo falla. La portada cuenta la idea; esto es para tenerlo abierto en otra pestaña mientras escribes el fetch. Si lo tuyo es Node y Express —que es lo normal—, empieza por llamar a esto desde tu proyecto.

Todos los ejemplos apuntan al servidor de clase, https://nfc.multimediaudeb.com/api/v1. Si levantas una copia en tu máquina, cambia esa base por http://localhost:3000/api/v1 y lo demás es idéntico.

Base
https://nfc.multimediaudeb.com/api/v1
Formato
JSON en los dos sentidos
Fechas
ISO 8601 en UTC
Dinero
enteros de pesos (COP)

La forma de un error

Todos los errores traen un código en snake_case en el campo error, y algunos añaden lo que hace falta para arreglarlo sin adivinar: el rango que se pasó, lo que falta de saldo, los valores válidos. El código HTTP dice de qué tipo es; el error, cuál es.

{ "error": "monto_fuera_de_rango", "minimo": 100, "maximo": 500000 }
{ "error": "saldo_insuficiente", "saldo": 1200, "falta": 1750 }
{ "error": "perfil_desconocido", "validos": ["general", "estudiante", "adulto_mayor", "empleado"] }

Programa contra el campo error, nunca contra el texto de ayuda: el código es parte del contrato y la ayuda está escrita para una persona, así que puede cambiar de redacción cualquier día.

Los sellos de cada ficha

Arriba de cada endpoint hay uno o dos sellos con lo que hace falta para llamarlo. Significan esto:

  • abierto Sin credenciales. Un fetch pelado desde cualquier sitio.
  • + clave: más campos Se puede llamar sin nada, pero mandar la clave amplía lo que devuelve. Es donde está el email, el saldo y el uid.
  • clave Obligatoria la cabecera X-API-Key. Sin ella, 401.
  • clave dueña Además, tiene que ser la clave que creó ese usuario o esa tarjeta. Solo lo piden las rutas que modifican o borran; mover saldo no. Si no, 403 no_es_tuyo.
  • token Obligatoria la cabecera Authorization: Bearer de un usuario que inició sesión.
  • profesor Pide X-Admin-Token. No es para los proyectos.

Paginación

Las listas —usuarios, tarjetas, movimientos— aceptan siempre los dos mismos parámetros de consulta y responden con la misma envoltura. Los valores fuera de rango se recortan en silencio en vez de dar error: pedir limite=9999 devuelve 200, y limite=-4, uno.

ParámetroTipoQué hace
limite entero Cuántos trae. Por defecto 50, máximo 200, mínimo 1.
desde entero Cuántos se salta. Por defecto 0. La página siguiente es desde + limite.
{ "total": 128, "limite": 50, "desde": 0, "usuarios": [ … ] }

total cuenta todo lo que cumple el filtro, no lo que vino en esta página: es lo que se usa para saber si hay que pedir otra.

Credenciales

Qué cambia con la clave y qué cambia con el token

Son dos cosas distintas y responden preguntas distintas. La clave dice desde qué proyecto se está escribiendo. El token dice quién está usándolo. No se sustituyen: hay rutas que piden una, otras la otra, y mover saldo pide las dos.

Cabecera

X-API-Key: tk_…

La clave, del proyecto

Te la da el profesor y es la misma todo el semestre. Firma lo que escribes: cada usuario, tarjeta y movimiento guarda cuál la creó. Vive en tu servidor o en tu .env, nunca en el JavaScript que baja al navegador de otro.

Cabecera

Authorization: Bearer eyJ…

El token, de la persona

Sale de /auth/register, /auth/login o /auth/refresh, y caduca a los 15 minutos. Vale en cualquier proyecto, porque el usuario es el mismo en todos.

Lo que se ve según lo que mandes

Leer es público en casi toda la API, pero público no es entero. Esta es la tabla que resuelve la mitad de las dudas:

Dato Sin nada Con tu clave Con el token de esa persona
id, nombre, iniciales, perfil, descuento, activo, creado
email, documento, saldo, actualizado, sin_credenciales no de todos solo los suyos
uid de una tarjeta en una lista no no
Crear usuarios y tarjetas no no
Mover el saldo de alguien no de cualquiera el suyo
Editar o borrar un usuario o una tarjeta no lo que creó tu clave su nombre y documento
Tarjetas de administrador nunca nunca nunca

Da igual de qué clave sea el usuario: cualquier clave válida abre los campos reservados y mueve el saldo de cualquier usuario. La clave no separa datos —la base es una y compartida—, decide si te enseñan la ficha completa y firma lo que escribes. De qué clave sea solo importa para una cosa: modificar o borrar un usuario o una tarjeta, que solo puede hacerlo la que lo creó.

Las dos cabeceras a la vez

curl

curl -X POST https://nfc.multimediaudeb.com/api/v1/wallet/topup \
  -H "X-API-Key: $CLAVE" \
  -H "Authorization: Bearer $ACCESO" \
  -H "Content-Type: application/json" \
  -d '{"monto": 5000, "concepto": "Recarga"}'

Node

await fetch("https://nfc.multimediaudeb.com/api/v1/wallet/topup", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.TUNJA_CLAVE,
    Authorization: `Bearer ${acceso}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ monto: 5000, concepto: "Recarga" }),
});

Si se te olvida el Content-Type: application/json, el cuerpo llega vacío y la API responde como si no hubieras mandado nada —monto_invalido, típicamente—. Es el error más común de la primera tarde. En fetch pasa lo mismo si mandas el objeto sin JSON.stringify.

GET

/key

clave

Qué clave estoy usando y cuánta cuota me queda. Es la ruta que se mira cuando la API empieza a responder 401 o 409.

Petición

GET /api/v1/key
X-API-Key: tk_9fK2xQm…

200 OK

{
  "prefijo": "tk_9fK2xQm",
  "estudiante": "Ana Ruiz",
  "usuarios": 14,
  "limite_usuarios": 200
}

Que responda 200 ya dice que tu clave está viva. Si devuelve 401 clave_invalida es que la revocaron o la copiaste mal; clave_requerida, que no llegó la cabecera. Del secreto solo se guarda su hash, así que aquí nunca vuelve a salir entera: por eso el prefijo, que sirve para reconocerla sin poder usarla.

Node y Express

Llamar a esto desde tu proyecto

Los ejemplos de cada endpoint están en curl y en HTTP crudo porque son la forma más corta de decir qué viaja por el cable. Pero lo que vas a escribir es Node, así que aquí está lo mismo con Express, que es lo que se copia y pega de verdad. No hace falta instalar nada para llamar: fetch viene dentro de Node desde la versión 18.

Dónde va la clave

Esta es la decisión que ordena todo lo demás. La clave es un secreto del proyecto: si la escribes en el JavaScript que baja al navegador, cualquiera que abra las herramientas de desarrollo la tiene, y con ella puede crear usuarios y mover saldo firmando con tu nombre. Así que la clave vive en tu servidor Express y nunca sale de ahí.

navegador  ──▶  tu Express          ──▶  TunjaAPI
(sin clave)     (X-API-Key aquí)         nfc.multimediaudeb.com

Del navegador a la API directamente solo van las cosas que no piden clave: el login, /users, /cards/:uid. El CORS está abierto justo para eso.

.env

TUNJA_API=https://nfc.multimediaudeb.com/api/v1
TUNJA_CLAVE=tk_9fK2xQm7vN3pR8sT1uW5yZ0

Arrancar leyendo ese archivo

# Node 20.6 o más nuevo: sin dotenv
node --env-file=.env server.js

Y .env al .gitignore antes del primer commit. Una clave subida a GitHub hay que revocarla y pedir otra; no se puede "borrar del historial" y quedarse tranquilo.

El cliente: un archivo, una función

Todo el proyecto llama a la API por aquí. Concentrarlo en una función evita repetir cabeceras en veinte sitios y, sobre todo, deja un solo lugar donde entender los errores: la API contesta con un código HTTP y un campo error, y eso se convierte en una excepción con esos dos datos dentro.

// api.js — el único archivo del proyecto que conoce la clave
const API = process.env.TUNJA_API ?? "https://nfc.multimediaudeb.com/api/v1";
const CLAVE = process.env.TUNJA_CLAVE;

export async function tunja(camino, { metodo = "GET", cuerpo, token } = {}) {
  const respuesta = await fetch(API + camino, {
    method: metodo,
    headers: {
      ...(cuerpo && { "Content-Type": "application/json" }),
      ...(CLAVE && { "X-API-Key": CLAVE }),
      ...(token && { Authorization: `Bearer ${token}` }),
    },
    body: cuerpo && JSON.stringify(cuerpo),
  });

  // Un 204 no trae cuerpo: pedirle .json() revienta.
  const datos = respuesta.status === 204 ? null : await respuesta.json();

  if (!respuesta.ok) {
    const fallo = new Error(datos?.error ?? "error_desconocido");
    fallo.estado = respuesta.status;   // 402, 409, 404…
    fallo.datos = datos;               // { error, falta, minimo… }
    throw fallo;
  }

  return datos;
}

Ojo con esto: fetch no lanza cuando la respuesta es un 402 o un 409 — solo si no hubo respuesta. Sin el if (!respuesta.ok), un cobro sin saldo pasa por bueno y tu torniquete abre la puerta gratis. Es el fallo clásico de la primera versión.

Una ruta de Express que registra gente

El formulario del navegador manda los datos a tu servidor, y es tu servidor —que tiene la clave— el que habla con TunjaAPI. De paso puedes traducir los errores al idioma de tu proyecto.

// server.js
import express from "express";
import { tunja } from "./api.js";

const app = express();
app.use(express.json());

app.post("/registro", async (req, res) => {
  const { email, contrasena, nombre } = req.body;

  try {
    const { usuario, acceso, refresco } = await tunja("/auth/register", {
      metodo: "POST",
      cuerpo: { email, contrasena, nombre, perfil: "estudiante" },
    });

    // El par de tokens es del usuario: se lo devuelves y tu servidor no lo guarda.
    res.status(201).json({ usuario, acceso, refresco });
  } catch (fallo) {
    if (fallo.message === "email_en_uso") {
      return res.status(409).json({ error: "Esa persona ya está en la base: que inicie sesión" });
    }
    if (fallo.message === "contrasena_debil") {
      return res.status(400).json({ error: `Contraseña débil: ${fallo.datos.motivo}` });
    }
    console.error(fallo);
    res.status(502).json({ error: "La API no respondió" });
  }
});

app.listen(3000);

Y otra que hace de torniquete

El caso completo en veinte líneas: llega un uid, se mira de quién es, se calcula la tarifa —que es tuya, no de la API— y se cobra.

const TARIFA = { general: 2950, estudiante: 1500, adulto_mayor: 0, empleado: 2500 };

app.post("/pasar", async (req, res) => {
  const uid = String(req.body.uid ?? "").toUpperCase();

  let tarjeta;
  try {
    tarjeta = await tunja(`/cards/${uid}`);
  } catch (fallo) {
    if (fallo.estado === 404) return res.json({ paso: false, motivo: "tarjeta_no_registrada" });
    throw fallo;
  }

  if (!tarjeta.activa) return res.json({ paso: false, motivo: "tarjeta_bloqueada" });

  const { id, perfil, descuento } = tarjeta.usuario;
  const monto = Math.round((TARIFA[perfil] ?? TARIFA.general) * (1 - descuento / 100));

  if (monto === 0) return res.json({ paso: true, monto: 0 });   // gratis: ni se apunta

  try {
    const cobro = await tunja(`/users/${id}/wallet/charge`, {
      metodo: "POST",
      cuerpo: { monto, concepto: "Pasaje" },
    });
    res.json({ paso: true, monto, saldo: cobro.saldo });
  } catch (fallo) {
    if (fallo.message === "saldo_insuficiente") {
      return res.json({ paso: false, motivo: "saldo_insuficiente", falta: fallo.datos.falta });
    }
    throw fallo;
  }
});

Fíjate en que saldo_insuficiente no se trata como un error del programa sino como un resultado posible: el torniquete no se rompe, no deja pasar. Lo mismo con el 404 de la tarjeta. Los throw que quedan son para lo que no esperabas — Express 5 recoge solo las promesas rechazadas y las lleva a su manejador de errores; si tu proyecto va con Express 4, envuelve el manejador en un try/catch o el proceso se cae.

Si prefieres axios

Funciona igual, con una diferencia que hay que tener presente: axios sí lanza con un 402 o un 409, y el cuerpo de la API llega en error.response.data.

import axios from "axios";

const tunja = axios.create({
  baseURL: process.env.TUNJA_API ?? "https://nfc.multimediaudeb.com/api/v1",
  headers: { "X-API-Key": process.env.TUNJA_CLAVE },
});

try {
  const { data } = await tunja.post(`/users/${id}/wallet/charge`, { monto: 1500 });
  console.log(data.saldo);
} catch (fallo) {
  const cuerpo = fallo.response?.data;          // { error: "saldo_insuficiente", falta: 1750 }
  if (cuerpo?.error === "saldo_insuficiente") console.log(`Faltan ${cuerpo.falta}`);
}

Guardar el token de cada usuario

Si tu proyecto tiene login, lo normal es que el navegador se quede con el par de tokens y los mande en cada llamada, y que tu Express los reenvíe cuando necesite actuar en nombre de esa persona. El acceso dura 15 minutos, así que hace falta refrescarlo: la tercera receta tiene ese trozo resuelto.

// El navegador manda su acceso; tu servidor solo lo pasa adelante.
app.post("/recargar", async (req, res) => {
  const acceso = req.get("Authorization")?.replace("Bearer ", "");
  if (!acceso) return res.status(401).json({ error: "inicia_sesion" });

  // Con token y clave a la vez: el usuario dice quién es, tu clave desde dónde.
  const movimiento = await tunja("/wallet/topup", {
    metodo: "POST",
    token: acceso,
    cuerpo: { monto: req.body.monto, concepto: "Recarga en línea" },
  });

  res.json(movimiento);
});

Guarda el refresco donde puedas, pero no lo trates como un dato más: quien lo tenga entra como esa persona durante 30 días. Y no guardes nunca contraseñas en tu proyecto — para eso está el login de la API.

Config

Lo que hay que saber antes de empezar

GET

/config

abierto

Perfiles válidos, moneda y límites vigentes. Léelo al arrancar tu proyecto en vez de copiar los números a mano: si aquí suben el tope de saldo, tu validación se entera sola.

Petición

GET /api/v1/config

200 OK

{
  "nombre": "TunjaAPI",
  "version": 1,
  "moneda": "COP",
  "perfiles": ["general", "estudiante",
               "adulto_mayor", "empleado"],
  "limites": {
    "montoMinimo": 100,
    "montoMaximo": 500000,
    "saldoMaximo": 2000000,
    "usuariosPorClave": 200,
    "peticionesPorMinuto": 120
  }
}

Qué es cada límite

LímiteValorQué pasa al pasarse
peticionesPorMinuto 120 429 demasiadas_peticiones con Retry-After: 60. Se cuenta por clave, o por IP si no mandas ninguna.
usuariosPorClave 200 409 cuota_agotada al registrar el 201.
montoMinimo · montoMaximo 100500 000 400 monto_fuera_de_rango en cada recarga o cobro.
saldoMaximo 2 000 000 409 supera_saldo_maximo; la recarga no se hace.
Tamaño del cuerpo 4 kB 400 cuerpo_muy_grande. No sale en /config.

Aquí no hay tarifas y no las va a haber. Lo de arriba son barandillas para que nadie llene la base de basura, no precios: cuánto vale un pasaje lo decide tu proyecto, y perfil y descuento son un dato de la persona, no una regla de cobro.

CORS y rutas que no existen

Todo /api/v1 responde con Access-Control-Allow-Origin: * y acepta Content-Type, Authorization, X-API-Key y X-Admin-Token. Puedes llamar desde localhost:5173, desde un Netlify o desde un index.html abierto a pelo. Las credenciales viajan en cabeceras y no hay cookies, que es justo lo que hace seguro el comodín.

Una ruta que no existe bajo la base responde 404 con { "error": "ruta_desconocida", "ayuda": "Ver /api/v1/config" }. Si te sale eso, casi siempre es una barra de más, un /api/users sin el /v1, o un método que esa ruta no acepta.

Autenticación

Registro, sesión y tokens

Un usuario se crea aquí y solo aquí: no hay POST /users, porque la contraseña es obligatoria y sin ella nadie podría entrar a su propia cuenta. Registro y login devuelven exactamente lo mismo —el usuario completo y un par de tokens—, así que después de registrar a alguien no hace falta iniciarle sesión.

POST

/auth/register

clave

Da de alta a una persona en la base compartida y devuelve su sesión ya abierta.

El usuario queda firmado con tu clave, y eso es lo que después te deja editarlo, vincularle tarjetas o moverle el saldo sin pedirle la contraseña. El email es único en toda la base: si tu compañera ya registró a esa persona, aquí sale 409 y lo que toca es que inicie sesión, no crear otra cuenta.

Cuerpo

CampoTipoQué es
email obligatorio texto Se recorta y se pasa a minúsculas antes de guardarlo, así que Ana@Ejemplo.com y ana@ejemplo.com son el mismo. Se comprueba que tenga arroba y un punto detrás, nada más.
contrasena obligatorio texto Mínimo 8 caracteres. Se rechaza si es de las diez más usadas del mundo o si es el propio email. Se guarda con scrypt; en claro no queda en ninguna parte.
nombre obligatorio texto Dos caracteres o más. De aquí salen las iniciales que devuelve la API: las dos primeras palabras, en mayúsculas.
documento texto Libre y sin validar: cédula, carné, lo que use tu proyecto. Se puede buscar por él en GET /users?q=.
perfil enum general (por defecto), estudiante, adulto_mayor o empleado. Es información sobre la persona; qué se hace con ella lo decides tú.
descuento entero Porcentaje de 0 a 100, por defecto 0. La API no lo aplica a nada —no sabe lo que cuestan las cosas—: lo guarda para que tu proyecto lo use al calcular.

Petición

POST /api/v1/auth/register
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{
  "email": "ana@ejemplo.com",
  "contrasena": "tunja2026",
  "nombre": "Ana Ruiz",
  "documento": "1.020.334.881",
  "perfil": "estudiante",
  "descuento": 0
}

201 Created

{
  "usuario": {
    "id": 7,
    "nombre": "Ana Ruiz",
    "iniciales": "AR",
    "perfil": "estudiante",
    "descuento": 0,
    "activo": true,
    "creado": "2026-07-31T06:31:26.988Z",
    "email": "ana@ejemplo.com",
    "documento": "1.020.334.881",
    "saldo": 0,
    "actualizado": "2026-07-31T06:31:26.988Z",
    "sin_credenciales": false
  },
  "acceso": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjcsInR2IjoxfQ…",
  "refresco": "x_70BjCLPw_bnx1bWspfZgTqbE4AJBEePZ3Kcr6lX4k",
  "expira_en": 900
}

expira_en son segundos: 900 es el cuarto de hora que dura el acceso. sin_credenciales marca a los usuarios que dio de alta el terminal del metro con una tarjeta y todavía no tienen contraseña; los que salen de aquí siempre la tienen.

Cuando falla

CódigoerrorCuándo
401clave_requeridaNo llegó la cabecera X-API-Key.
401clave_invalidaLlegó, pero no es una clave viva.
400email_invalidoFalta la arroba, el punto, o el campo entero.
400contrasena_debilTrae motivo y minimo: corta, obvia o igual al email.
400nombre_requeridoMenos de dos caracteres.
400perfil_desconocidoTrae validos con la lista.
400descuento_invalidoNo es entero o se sale de 0–100.
409email_en_usoYa hay alguien con ese email en la base compartida.
409cuota_agotadaTu clave llegó a su tope de usuarios. Trae limite.
POST

/auth/login

abierto

Abre sesión con email y contraseña. Devuelve lo mismo que el registro.

No pide clave a propósito: el usuario ya existe y entrar no ensucia nada, así que un front sin servidor puede tener login sin llevar encima ningún secreto. Y funciona con cualquiera de la base: alguien registrado desde el proyecto de tu compañera inicia sesión en el tuyo con su misma contraseña y su mismo saldo.

Cuerpo

CampoTipoQué es
email obligatorio texto Se normaliza igual que en el registro: no importan mayúsculas ni espacios de sobra.
contrasena obligatorio texto La que puso al registrarse.

Petición

POST /api/v1/auth/login
Content-Type: application/json

{ "email": "ana@ejemplo.com",
  "contrasena": "tunja2026" }

200 OK

{
  "usuario": { "id": 7, "nombre": "Ana Ruiz", … },
  "acceso": "eyJhbGciOiJIUzI1NiJ9…",
  "refresco": "x_70BjCLPw_bnx1bWspfZ…",
  "expira_en": 900
}

Cuando falla

CódigoerrorCuándo
401 credenciales_invalidas Email que no existe o contraseña mala: dice lo mismo en los dos casos, y tarda lo mismo. Distinguirlos sería más amable y de paso regalaría la lista de qué emails están registrados.
429 demasiados_intentos Cinco fallos seguidos con ese email desde esa IP. Trae reintentar_en en segundos y la espera se duplica con cada intento, hasta 15 minutos.
403usuario_inactivoLa contraseña era buena, pero la cuenta está bloqueada.
POST

/auth/refresh

abierto

Canjea el refresco por un par nuevo, sin volver a pedir la contraseña.

Es lo que hace tu cliente cuando le llega un 401 token_expirado: refresca y reintenta la llamada una vez. Cada refresco sirve una sola vez y el que devuelve esta ruta es el nuevo, así que hay que guardarlo pisando al anterior. Si guardas el viejo por descuido, la siguiente llamada cerrará todas las sesiones de esa persona.

Cuerpo

CampoTipoQué es
refresco obligatorio texto El último que te dieron. Dura 30 días desde que se emitió.

Petición

POST /api/v1/auth/refresh
Content-Type: application/json

{ "refresco": "x_70BjCLPw_bnx1bWspfZ…" }

200 OK

{
  "usuario": { "id": 7, … },
  "acceso": "eyJhbGciOiJIUzI1NiJ9…",   ← nuevo
  "refresco": "y_9dTkQ1sM_pw82…",      ← nuevo
  "expira_en": 900
}

Cuando falla

CódigoerrorCuándo
401refresco_invalidoNo es una cadena, o no corresponde a ninguna sesión.
401 refresco_reutilizado Ese refresco ya se canjeó. El servidor asume que hay una copia circulando y cierra todas las sesiones de ese usuario: toca volver a iniciar sesión. Es molesto a propósito.
401refresco_revocadoSe cerró esa sesión, o se cerraron todas al cambiar la contraseña.
401refresco_expiradoPasaron los 30 días.
401usuario_inactivoLa cuenta se bloqueó mientras la sesión estaba abierta.

Los cinco significan lo mismo para tu código: borra lo que tengas guardado y manda a esa persona al login. Distinguirlos solo sirve para el mensaje que le enseñas.

POST

/auth/logout

abierto

Cierra esa sesión. Responde 204 sin cuerpo.

Revoca el refresco que le mandes; las demás sesiones de esa persona siguen abiertas, porque cerrar la del móvil no tiene por qué echarla del portátil. El acceso que ya emitió sigue valiendo hasta que caduque —es un JWT, no se puede apagar—, así que borra también el tuyo del cliente.

Responde 204 aunque el refresco no exista o ya estuviera cerrado: no hay nada que contarle a quien prueba cadenas al azar, y para tu código el resultado es el mismo.

Petición

POST /api/v1/auth/logout
Content-Type: application/json

{ "refresco": "x_70BjCLPw_bnx1bWspfZ…" }

204 No Content

(sin cuerpo)
GET

/auth/me

token

La ficha completa de quien lleva el token. Idéntica a GET /users/me.

Existen las dos porque se buscan en sitios distintos: esta al lado del login, la otra al lado de los usuarios. Sirve además para comprobar si el acceso sigue vivo antes de pintar una pantalla.

Petición

GET /api/v1/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{
  "id": 7, "nombre": "Ana Ruiz", "iniciales": "AR",
  "perfil": "estudiante", "descuento": 0,
  "activo": true, "creado": "2026-07-31T06:31:26.988Z",
  "email": "ana@ejemplo.com",
  "documento": "1.020.334.881",
  "saldo": 47050,
  "actualizado": "2026-07-31T18:02:11.401Z",
  "sin_credenciales": false
}
POST

/auth/password

token

Cambia la contraseña y devuelve un par de tokens nuevo. Todo lo anterior deja de valer al instante.

Se invalidan los accesos ya emitidos y se cierran todas las sesiones, incluida la que hizo la llamada: hay que volver a entrar en todas partes, que es justo lo que se espera cuando alguien cambia la contraseña porque cree que se la saben. Por eso la respuesta trae un par nuevo — el cliente que llamó no tiene que pedir login otra vez.

Cómo se apaga un JWT que el servidor no guarda: el acceso lleva dentro un número de versión (tv) que también está en la fila del usuario. Al cambiar la contraseña ese número sube, y los tokens viejos dejan de cuadrar → 401 token_caducado_por_cambio.

Cuerpo

CampoTipoQué es
actual obligatorio texto La de ahora. Tener el token no basta para cambiarla.
nueva obligatorio texto Pasa por las mismas reglas que en el registro.

Petición

POST /api/v1/auth/password
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json

{ "actual": "tunja2026",
  "nueva": "boyaca-2027!" }

200 OK

{
  "usuario": { "id": 7, … },
  "acceso": "eyJ…",     ← guarda estos
  "refresco": "z_1pQ…",
  "expira_en": 900
}

Cuando falla

CódigoerrorCuándo
403contrasena_actual_incorrectaactual no coincide.
400contrasena_debilLa nueva no pasa las reglas. Trae motivo.
GET

/auth/sessions

token

Dónde tiene esa persona la sesión abierta.

Solo las que siguen vivas: ni canjeadas, ni revocadas, ni vencidas. Como cada refresco se gasta al usarlo, una sesión que refresca a menudo cambia de id; lo que se lista no es "dispositivos", es refrescos por estrenar.

Petición

GET /api/v1/auth/sessions
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{
  "sesiones": [
    {
      "id": 41,
      "creada": "2026-07-31T18:02:11.401Z",
      "expira": "2026-08-30T18:02:11.401Z",
      "usada_en": null,
      "agente": "Mozilla/5.0 (Linux; Android 14)…",
      "ip": "::1"
    }
  ]
}

El refresco no sale aquí ni sale nunca: en la base solo está su hash. Esta lista sirve para enseñarla y para cerrar sesiones por id, no para recuperar una.

DELETE

/auth/sessions/:id

token

Cierra una sesión concreta de las que devuelve la lista.

Solo cierra sesiones propias: mandar el id de la sesión de otra persona da 404, igual que un id inventado o una que ya estaba cerrada. Responde 204 sin cuerpo.

Petición

DELETE /api/v1/auth/sessions/41
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

404 · si no era tuya

{ "error": "sesion_no_encontrada" }
DELETE

/auth/sessions

token

Cierra todas. El botón de "me robaron el celular", con la cuenta de cuántas cayeron.

Petición

DELETE /api/v1/auth/sessions
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{ "cerradas": 3 }

Cierra los refrescos, no los accesos: los que ya estén emitidos aguantan lo que les quede de sus 15 minutos. Para cortar también esos, la vía es cambiar la contraseña.

Usuarios

La gente

Es la parte donde más se nota que la base es compartida: leer está abierto a todo el mundo y devuelve a los usuarios de todos los proyectos, pero los campos reservados piden clave y escribir pide la clave que los creó. Aquí no hay POST: las altas van por /auth/register, donde la contraseña es obligatoria.

GET

/users

abierto + clave: más campos

La lista de la base entera, paginada, con búsqueda y filtro por perfil.

Sirve para un tablero, para un selector de personas o para ver qué hay ahí sin tener todavía la clave. Vienen ordenados por id ascendente, o sea por antigüedad, y total cuenta los que cumplen el filtro, no los de esta página.

Consulta

ParámetroTipoQué hace
limite entero 1–200, por defecto 50. Lo que se sale se recorta sin dar error.
desde entero Cuántos se salta. Por defecto 0.
perfil enum Filtra por general, estudiante, adulto_mayor o empleado. Uno inventado da 400, no una lista vacía.
q texto Busca el texto dentro del nombre o del documento. No distingue mayúsculas y no hace falta que sea el principio. Se recorta a 60 caracteres.

Qué cambia con la clave

Sin credenciales

GET /api/v1/users?limite=2&perfil=estudiante

{
  "total": 34,
  "limite": 2,
  "desde": 0,
  "usuarios": [
    {
      "id": 7,
      "nombre": "Ana Ruiz",
      "iniciales": "AR",
      "perfil": "estudiante",
      "descuento": 0,
      "activo": true,
      "creado": "2026-07-31T06:31:26.988Z"
    },
    { "id": 9, "nombre": "Luis Pérez", … }
  ]
}

Con X-API-Key

GET /api/v1/users?limite=2&perfil=estudiante
X-API-Key: tk_9fK2xQm…

{
  "total": 34,
  "limite": 2,
  "desde": 0,
  "usuarios": [
    {
      "id": 7,
      "nombre": "Ana Ruiz",
      "iniciales": "AR",
      "perfil": "estudiante",
      "descuento": 0,
      "activo": true,
      "creado": "2026-07-31T06:31:26.988Z",
      "email": "ana@ejemplo.com",        ← nuevo
      "documento": "1.020.334.881",      ← nuevo
      "saldo": 47050,                    ← nuevo
      "actualizado": "2026-07-31T18:02:11.401Z",
      "sin_credenciales": false
    },
    { "id": 9, … }
  ]
}

Cuidado con un detalle: q busca también dentro del documento aunque sin clave el documento no se enseñe. Es decir, se puede comprobar si un número está en la base, pero no leerlo ni listarlo. Es el mismo trato que el uid de una tarjeta — buscar uno que ya tienes, sí; llevarse la lista, no.

Cuando falla

CódigoerrorCuándo
400perfil_desconocidoEl perfil no es uno de los cuatro. Trae validos.
GET

/users/me

token

La ficha completa de quien lleva el token, con email, documento y saldo.

Es la misma respuesta de GET /auth/me. Ojo con el orden de las rutas: /users/me se resuelve antes que /users/:id, así que me nunca se confunde con un id.

Petición

GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{
  "id": 7, "nombre": "Ana Ruiz", "iniciales": "AR",
  "perfil": "estudiante", "descuento": 0,
  "activo": true, "creado": "2026-07-31T06:31:26.988Z",
  "email": "ana@ejemplo.com",
  "documento": "1.020.334.881",
  "saldo": 47050,
  "actualizado": "2026-07-31T18:02:11.401Z",
  "sin_credenciales": false
}
PUT

/users/me

token

El usuario editándose a sí mismo. Solo nombre y documento.

Funciona desde cualquier proyecto, lo haya creado la clave que sea: la credencial es de la persona, no del proyecto. El perfil y el descuento no están aquí a propósito —nadie se declara adulto mayor para pagar menos—: los cambia la clave que dio de alta a esa persona, en PUT /users/:id.

Manda solo lo que quieras cambiar: los campos ausentes se quedan como estaban, aunque el verbo sea PUT. Y no cambia el email ni la contraseña —eso es /auth/password—.

Cuerpo

CampoTipoQué es
nombretextoDos caracteres o más. Ojo: las iniciales no se recalculan.
documentotextoLibre. Se recorta por los lados.

Petición

PUT /api/v1/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json

{ "documento": "1020334881" }

200 OK · la ficha entera

{
  "id": 7, "nombre": "Ana Ruiz", …,
  "documento": "1020334881",
  "actualizado": "2026-07-31T19:14:02.115Z"
}

Cuando falla

CódigoerrorCuándo
400nombre_requeridoMandaste nombre con menos de dos caracteres.
GET

/users/:id

abierto + clave: más campos

Una persona por su id.

Mismos campos y mismas reglas que la lista: sin credenciales sale la vista pública, con cualquier clave válida salen además email, documento, saldo, actualizado y sin_credenciales. Aquí el token no abre nada: quien quiera su propia ficha completa sin tener clave la pide en /users/me, que es la ruta que entiende de tokens.

Sin credenciales

GET /api/v1/users/7

{
  "id": 7,
  "nombre": "Ana Ruiz",
  "iniciales": "AR",
  "perfil": "estudiante",
  "descuento": 0,
  "activo": true,
  "creado": "2026-07-31T06:31:26.988Z"
}

Con X-API-Key

GET /api/v1/users/7
X-API-Key: tk_9fK2xQm…

{
  "id": 7,
  "nombre": "Ana Ruiz",
  "iniciales": "AR",
  "perfil": "estudiante",
  "descuento": 0,
  "activo": true,
  "creado": "2026-07-31T06:31:26.988Z",
  "email": "ana@ejemplo.com",
  "documento": "1.020.334.881",
  "saldo": 47050,
  "actualizado": "2026-07-31T18:02:11.401Z",
  "sin_credenciales": false
}

Cuando falla

CódigoerrorCuándo
404usuario_no_encontradoEse id no está. También si mandas algo que no es un número.
GET

/users/:id/cards

abierto + clave: el uid

Las tarjetas de una persona, ordenadas por antigüedad.

Cuántas lleva y desde cuándo se ve sin nada; los uid, no. Cruzar un uid con su dueño es media gracia de la API —eso lo hace GET /cards/:uid—, pero al revés, sacar el uid partiendo de la persona, es justo la lista que hace falta para clonarlas.

El campo usuario viene en null: ya sabes de quién son, las pediste por su id. Y no hay paginación aquí, porque nadie lleva cuarenta tarjetas.

Sin credenciales

GET /api/v1/users/7/cards

{
  "tarjetas": [
    {
      "activa": true,
      "creada": "2026-07-31T06:40:02.310Z",
      "usuario": null
    }
  ]
}

Con X-API-Key

GET /api/v1/users/7/cards
X-API-Key: tk_9fK2xQm…

{
  "tarjetas": [
    {
      "uid": "04A1B2C3",     ← nuevo
      "admin": false,        ← nuevo
      "activa": true,
      "creada": "2026-07-31T06:40:02.310Z",
      "usuario": null
    }
  ]
}

Si esa persona lleva una tarjeta de administrador, no sale ni con clave: no aparece en la lista ni se puede consultar por uid. Es el único uid del sistema que abre algo de verdad —la configuración del terminal— y solo se le enseña al profesor.

Cuando falla

CódigoerrorCuándo
404usuario_no_encontradoEse id no está. Si existe y no tiene tarjetas, sale { "tarjetas": [] }.
PUT

/users/:id

clave dueña

Editar a alguien que registró tu clave: nombre, documento, perfil, descuento y si está activo.

Esta es la ruta de la institución, no la de la persona. Aquí es donde se marca a alguien como adulto_mayor, se le pone un descuento o se le bloquea la cuenta. Sobre usuarios de otra clave da 403: los ves, no se los tocas.

Solo se escriben los campos que mandes. Como en /users/me, no cambia email ni contraseña.

Cuerpo

CampoTipoQué es
nombretextoDos caracteres o más.
documentotextoLibre.
perfilenumUno de los cuatro de /config.
descuentoentero0 a 100. La API sigue sin aplicarlo a nada.
activo booleano En false, esa persona no puede iniciar sesión ni recibir ni gastar saldo: todo le responde 403 usuario_inactivo. Es el bloqueo, y se deshace poniéndolo en true.

Petición

PUT /api/v1/users/7
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{ "perfil": "adulto_mayor",
  "descuento": 100 }

200 OK · la ficha entera

{
  "id": 7, "nombre": "Ana Ruiz", "iniciales": "AR",
  "perfil": "adulto_mayor",
  "descuento": 100,
  "activo": true,
  "creado": "2026-07-31T06:31:26.988Z",
  "email": "ana@ejemplo.com",
  "documento": "1.020.334.881",
  "saldo": 47050,
  "actualizado": "2026-07-31T19:20:44.902Z",
  "sin_credenciales": false
}

Cuando falla

CódigoerrorCuándo
401clave_requerida · clave_invalidaSin clave viva no se escribe.
404no_encontradoEse id no existe. Es el genérico de las rutas de dueño.
403no_es_tuyoEse usuario lo creó otra clave.
400nombre_requerido · perfil_desconocido · descuento_invalidoLos mismos que en el registro.
DELETE

/users/:id

clave dueña

Borra a esa persona y, con ella, sus tarjetas, movimientos y sesiones. Responde 204.

Es un borrado de verdad y no se puede deshacer: el saldo desaparece con el usuario. Para quitar a alguien de en medio sin perder su historial, lo que se usa es activo: false en PUT /users/:id.

Petición

DELETE /api/v1/users/7
X-API-Key: tk_9fK2xQm…

204 No Content

(sin cuerpo)

Cuando falla

CódigoerrorCuándo
404no_encontradoEse id no existe.
403no_es_tuyoLo creó otra clave.

Tarjetas

Las tarjetas NFC

Una tarjeta es una llave, no una cuenta: el saldo vive en el usuario. De ahí salen dos cosas que conviene tener claras antes de escribir nada — una persona puede llevar varias, y perder una no le cuesta el dinero, porque se le vincula otra y el saldo ni se entera.

El uid es lo que suelta el chip: hexadecimal, de 4 a 32 caracteres. Da igual en qué caso lo mandes —en la URL o en el cuerpo—, se guarda y se compara en mayúsculas, así que 04a1b2c3 y 04A1B2C3 son la misma tarjeta.

GET

/cards

abierto + clave: el uid

Todas las tarjetas, de la más nueva a la más vieja, con el usuario de cada una incrustado.

Sin clave dice cuántas hay, de quién son y si están activas, pero no los uid. Con clave salen enteras. Las de administrador no salen nunca por aquí, ni siquiera contadas en el total.

Consulta

ParámetroTipoQué hace
limiteentero1–200, por defecto 50.
desdeenteroCuántas se salta. Por defecto 0.

Qué cambia con la clave

Sin credenciales

GET /api/v1/cards?limite=1

{
  "total": 96,
  "limite": 1,
  "desde": 0,
  "tarjetas": [
    {
      "activa": true,
      "creada": "2026-07-31T06:40:02.310Z",
      "usuario": {
        "id": 7,
        "nombre": "Ana Ruiz",
        "iniciales": "AR",
        "perfil": "estudiante",
        "descuento": 0,
        "activo": true,
        "creado": "2026-07-31T06:31:26.988Z"
      }
    }
  ]
}

Con X-API-Key

GET /api/v1/cards?limite=1
X-API-Key: tk_9fK2xQm…

{
  "total": 96,
  "limite": 1,
  "desde": 0,
  "tarjetas": [
    {
      "uid": "04A1B2C3",   ← nuevo
      "admin": false,      ← nuevo
      "activa": true,
      "creada": "2026-07-31T06:40:02.310Z",
      "usuario": {
        "id": 7,
        "nombre": "Ana Ruiz",
        …,
        "email": "ana@ejemplo.com",
        "documento": "1.020.334.881",
        "saldo": 47050,
        "actualizado": "2026-07-31T18:02:11.401Z",
        "sin_credenciales": false
      }
    }
  ]
}

Por qué el uid pide clave

Un uid no es un secreto: lo lee cualquier móvil acercándose a la tarjeta, así que taparlo de una en una no protegería de nada. Una lista de uids sí es otra cosa — un curl sin cabeceras equivalía a pasar un lector por todos los bolsillos del campus a la vez. Por eso el uid viaja con el email y el saldo, en la parte que pide clave, mientras que GET /cards/:uid sigue abierto: si ya tienes el uid es porque tienes la tarjeta.

Lo que esto no arregla, para que quede dicho: una tarjeta se autentica por su uid y nada más, así que quien consiga uno y tenga una etiqueta regrabable tiene una copia funcionando. Cerrar la lista sube el coste del ataque, no lo elimina. La defensa de verdad es criptografía en el propio chip —DESFire, NTAG con firma— y aquí no está hecha. Tenlo en cuenta antes de usar esto para algo que importe.

GET

/cards/:uid

abierto + clave: más campos

De quién es esta tarjeta. Es la ruta del torniquete: entra un uid, sale una persona.

Abierta a propósito y sin clave: identificar a alguien que acaba de acercar su tarjeta a tu lector es el caso de uso central, y quien tiene el uid ya tiene la tarjeta en la mano. Con clave viene además el saldo, que es lo que necesitas si vas a cobrar.

Sin credenciales

GET /api/v1/cards/04a1b2c3

{
  "activa": true,
  "creada": "2026-07-31T06:40:02.310Z",
  "usuario": {
    "id": 7,
    "nombre": "Ana Ruiz",
    "iniciales": "AR",
    "perfil": "estudiante",
    "descuento": 0,
    "activo": true,
    "creado": "2026-07-31T06:31:26.988Z"
  }
}

Con X-API-Key

GET /api/v1/cards/04a1b2c3
X-API-Key: tk_9fK2xQm…

{
  "uid": "04A1B2C3",
  "admin": false,
  "activa": true,
  "creada": "2026-07-31T06:40:02.310Z",
  "usuario": {
    "id": 7,
    "nombre": "Ana Ruiz",
    …,
    "email": "ana@ejemplo.com",
    "saldo": 47050,
    "sin_credenciales": false
  }
}

activa: false no impide leer la tarjeta: la API te dice que existe y está bloqueada, y decidir qué hacer con eso —no abrir el torniquete— es de tu proyecto. Cobrar sobre una tarjeta bloqueada tampoco lo impide la API, porque el saldo es del usuario y no de la tarjeta: ese control es tuyo.

Cuando falla

CódigoerrorCuándo
404 tarjeta_no_registrada Ese uid no está vinculado a nadie… o es de administrador. Las dos cosas responden lo mismo, a propósito: un 403 confirmaría que ese uid existe y es de las buenas, que es justo lo que no se quiere decir.
POST

/cards

clave dueña del usuario

Vincula una tarjeta a un usuario que creó tu clave.

El usuario tiene que ser tuyo: si no, cualquiera podría colgarle tarjetas a la gente de otro proyecto. Un uid solo puede estar vinculado a una persona a la vez, así que reasignar una tarjeta es DELETE y volver a crearla.

Cuerpo

CampoTipoQué es
uid obligatorio texto Hexadecimal, de 4 a 32 caracteres: 0-9 y A-F, sin dos puntos ni espacios. Se guarda en mayúsculas.
usuario_id obligatorio entero El dueño. Tiene que haberlo registrado tu clave.
activa booleano Por defecto true. Solo nace bloqueada si mandas exactamente false.

Petición

POST /api/v1/cards
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{ "uid": "04A1B2C3",
  "usuario_id": 7 }

201 Created

{
  "uid": "04A1B2C3",
  "admin": false,
  "activa": true,
  "creada": "2026-07-31T06:40:02.310Z",
  "usuario": {
    "id": 7, "nombre": "Ana Ruiz", …,
    "email": "ana@ejemplo.com",
    "saldo": 47050
  }
}

¿De dónde sale el uid? En Chrome sobre Android, de la Web NFC del propio teléfono; el carné estudiantil ya suelta el suyo sin lector aparte. Cualquier cadena hexadecimal vale para probar mientras tanto: 04A1B2C3 es tan buena como una de verdad.

Cuando falla

CódigoerrorCuándo
401clave_requerida · clave_invalidaSin clave viva no se escribe.
400uid_invalidoNo es hexadecimal o no mide entre 4 y 32. Trae formato.
404usuario_no_encontradoEse usuario_id no existe.
403no_es_tuyoEl usuario existe pero lo registró otra clave.
409uid_en_usoEsa tarjeta ya está vinculada, aunque sea a alguien de otro proyecto.
PATCH

/cards/:uid

clave dueña

Bloquea o desbloquea una tarjeta sin tocar al usuario ni a su saldo.

Es lo que se hace cuando alguien la pierde: se bloquea, y si aparece se desbloquea. Lo único que se puede cambiar de una tarjeta es esto — ni el uid ni el dueño se editan, para eso está borrarla y crearla otra vez.

Cuerpo

CampoTipoQué es
activa obligatorio booleano Un cuerpo sin este campo da 400: no hay nada más que cambiar.

Petición

PATCH /api/v1/cards/04A1B2C3
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{ "activa": false }

200 OK

{
  "uid": "04A1B2C3",
  "admin": false,
  "activa": false,
  "creada": "2026-07-31T06:40:02.310Z",
  "usuario": { "id": 7, … }
}

Cuando falla

CódigoerrorCuándo
400activa_requeridaNo mandaste activa.
404no_encontradoEse uid no está vinculado —o es de administrador—.
403no_es_tuyoLa vinculó otra clave.
DELETE

/cards/:uid

clave dueña

Desvincula la tarjeta. El usuario, su saldo y su historial se quedan donde estaban. Responde 204.

Después de esto el uid queda libre y se le puede vincular a otra persona. Los movimientos que se hicieron con esa tarjeta siguen en el historial con su uid apuntado; lo que desaparece es el vínculo, no lo que pasó.

Petición

DELETE /api/v1/cards/04A1B2C3
X-API-Key: tk_9fK2xQm…

204 No Content

(sin cuerpo)

Cuando falla

CódigoerrorCuándo
404no_encontradoEse uid no está vinculado —o es de administrador—.
403no_es_tuyoLa vinculó otra clave.

Billetera

El saldo y sus movimientos

La API no sabe lo que valen las cosas. Le pasas un número de pesos y lo mueve; cuánto cuesta un pasaje, un café o una entrada es cosa de tu proyecto, y concepto es texto libre para que apuntes qué era. Los montos son enteros: nada de decimales, que en pesos no hacen falta.

Hay dos maneras de mover saldo. Con el token del usuario (/wallet/…), que es la persona autorizándolo ella misma. Y con la clave, sobre un usuario concreto (/users/:id/wallet/…), que es lo que hace un torniquete o una ventanilla, con esa persona delante pero sin sesión iniciada.

El saldo se cruza entre proyectos, y es a propósito

Cualquier clave viva puede mover el saldo de cualquier usuario, lo haya registrado quien lo haya registrado. No es un descuido: es para lo que existe una sola base. Si el torniquete de quien monta el metro no pudiera cobrarle a alguien que registró la cafetería, tendríamos dieciocho bases incomunicadas con otro nombre.

Lo que no se cruza es el usuario en sí: editarlo, bloquearlo o borrarlo sigue siendo de la clave que lo dio de alta, y lo mismo sus tarjetas. Eso es lo que de verdad se rompe si lo toca otro; un saldo que sube y baja es justo lo que se espera que pase entre proyectos.

Lo que sostiene esto no es un permiso, es el rastro: cada movimiento guarda qué clave lo hizo, así que un cobro raro tiene nombre. Y de ahí sale la única regla que queda, que no la impone el servidor: no le muevas el saldo a la gente de otro proyecto mientras pruebas. Cóbrale a los tuyos, o a quien tengas delante. Ver un saldo ajeno bajar sin explicación en mitad de una entrega es exactamente la clase de tarde que nadie quiere.

Qué es un movimiento

CampoTipoQué es
identeroIdentificador del apunte.
operacion texto Desde la API, recarga o cobro. En el historial de alguien que usa el metro aparecen además las que escribe el terminal: pasaje, alta, registro. Trátalo como texto, no como un enum cerrado.
montoenteroSiempre positivo: el signo lo dice operacion.
saldoenteroCómo quedó después de este movimiento.
conceptotexto · nullLo que escribiste al moverlo. Máximo 120 caracteres.
uid texto · null Con qué tarjeta se hizo. null en todo lo que viene de esta API; lo rellena el terminal del metro cuando cobra un pasaje.
fechaISO 8601Cuándo. La lista viene de la más reciente a la más vieja.

Un cobro que falla no deja rastro

O se descuenta y se apunta, o no pasa nada: la comprobación del saldo viaja dentro del mismo UPDATE, así que no hay hueco entre leer y escribir. Diez cobros simultáneos sobre un saldo que da para seis dejan exactamente seis movimientos y cuatro 402. Puedes lanzar peticiones en paralelo sin miedo a que un pasaje salga gratis.

GET

/wallet

token

Saldo y los 20 últimos movimientos de quien lleva el token. La pantalla de "mi billetera", en una llamada.

No acepta paginación: son siempre los 20 últimos. Para recorrer el historial entero está /wallet/movements.

Petición

GET /api/v1/wallet
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{
  "saldo": 47050,
  "moneda": "COP",
  "movimientos": [
    {
      "id": 341,
      "operacion": "cobro",
      "monto": 2950,
      "saldo": 47050,
      "concepto": "Pasaje",
      "uid": null,
      "fecha": "2026-07-31T18:02:11.401Z"
    },
    {
      "id": 338,
      "operacion": "recarga",
      "monto": 50000,
      "saldo": 50000,
      "concepto": "Recarga",
      "uid": null,
      "fecha": "2026-07-31T06:44:55.020Z"
    }
  ]
}
GET

/wallet/movements

token

El historial completo, paginado.

Consulta

ParámetroTipoQué hace
limiteentero1–200, por defecto 50.
desdeenteroCuántos se salta. Por defecto 0.

Petición

GET /api/v1/wallet/movements?limite=2&desde=20
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

200 OK

{
  "total": 63,
  "limite": 2,
  "desde": 20,
  "movimientos": [
    {
      "id": 210,
      "operacion": "pasaje",
      "monto": 1500,
      "saldo": 28300,
      "concepto": "Línea 1 · Plaza de Bolívar",
      "uid": "04A1B2C3",
      "fecha": "2026-07-28T13:05:41.882Z"
    },
    { "id": 208, … }
  ]
}

Ese movimiento con uid y operación pasaje lo escribió el terminal del metro, no la API. Es la prueba de que la base es una sola: tu proyecto y el demo le apuntan al mismo historial.

POST

/wallet/topup

token clave

Le sube saldo a quien lleva el token.

Pide las dos credenciales: el token dice quién es y la clave, desde qué proyecto se hizo. Toda escritura queda firmada, también esta. La clave no tiene que ser la que registró a esa persona —basta con que sea válida—, porque quien autoriza aquí es el propio usuario con su token.

Cuerpo

CampoTipoQué es
monto obligatorio entero Positivo y entre 100 y 500 000. Nada de "5000" entre comillas ni 2950.5.
concepto texto Libre, hasta 120 caracteres. Lo que pongas es lo que verá esa persona en su historial: "Recarga en ventanilla" se entiende, "topup" no.

Petición

POST /api/v1/wallet/topup
X-API-Key: tk_9fK2xQm…
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json

{ "monto": 50000,
  "concepto": "Recarga en ventanilla" }

200 OK

{
  "operacion": "recarga",
  "monto": 50000,
  "saldo": 97050,
  "usuario_id": 7
}

La respuesta trae el saldo ya actualizado: no hace falta volver a pedir /wallet para pintarlo.

Cuando falla

CódigoerrorCuándo
401token_requerido · token_expiradoFalta el token o ya caducó: refresca y reintenta.
401clave_requerida · clave_invalidaEl token solo no basta para escribir.
400monto_invalidoNo es un entero mayor que cero.
400monto_fuera_de_rangoTrae minimo y maximo.
409supera_saldo_maximoEl saldo resultante pasaría del tope. Trae saldo y saldoMaximo; no se recarga nada.
403usuario_inactivoLa cuenta está bloqueada.
POST

/wallet/charge

token clave

Le descuenta saldo a quien lleva el token. Igual que la recarga, cambiado el signo.

Mismo cuerpo, mismas credenciales, mismos límites de monto. Lo que cambia es el error nuevo: si no alcanza, 402 con cuánto falta, y no se descuenta nada.

Petición

POST /api/v1/wallet/charge
X-API-Key: tk_9fK2xQm…
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json

{ "monto": 2950, "concepto": "Pasaje" }

200 OK · y 402 si no alcanza

{
  "operacion": "cobro",
  "monto": 2950,
  "saldo": 44100,
  "usuario_id": 7
}

// 402 Payment Required
{
  "error": "saldo_insuficiente",
  "saldo": 1200,
  "falta": 1750
}

falta es exactamente lo que hay que recargar para que el cobro pase. Es el dato con el que se escribe un mensaje útil —"te faltan $1.750"— en vez de un "saldo insuficiente" a secas.

Cuando falla

CódigoerrorCuándo
402saldo_insuficienteTrae saldo y falta. No se descontó nada.
400monto_invalido · monto_fuera_de_rangoIgual que en la recarga.
403usuario_inactivoLa cuenta está bloqueada.
401token_… · clave_…Faltan credenciales o no valen.
GET

/users/:id/wallet

clave

El saldo y el historial de otra persona, sin su token.

Basta con tener una clave válida, sea o no la que registró a esa persona. Es la consulta del torniquete o de la ventanilla, donde el usuario está delante pero no va a iniciar sesión: se mira si le alcanza y se cobra con /users/:id/wallet/charge.

Consulta

ParámetroTipoQué hace
limiteenteroCuántos movimientos trae: 1–200, por defecto 50.
desdeenteroCuántos se salta. Por defecto 0.

Petición

GET /api/v1/users/7/wallet?limite=1
X-API-Key: tk_9fK2xQm…

200 OK

{
  "usuario_id": 7,
  "saldo": 47050,
  "moneda": "COP",
  "movimientos": [
    {
      "id": 341,
      "operacion": "cobro",
      "monto": 2950,
      "saldo": 47050,
      "concepto": "Pasaje",
      "uid": null,
      "fecha": "2026-07-31T18:02:11.401Z"
    }
  ]
}

Aquí no viene total: si necesitas contar movimientos, esa cuenta la da /wallet/movements, que va con el token de la persona.

Cuando falla

CódigoerrorCuándo
401clave_requerida · clave_invalidaUn saldo no se enseña sin credenciales.
404usuario_no_encontradoEse id no está.
POST

/users/:id/wallet/topup

clave

Recarga en ventanilla: le subes saldo a alguien sin pedirle la contraseña.

Mismo cuerpo y mismos errores que /wallet/topup, pero sin token: aquí quien autoriza es tu clave. Vale sobre cualquier usuario de la base, lo registrara tu proyecto o el de al lado — el saldo es lo único que se cruza entre proyectos a propósito. Recuerda que el movimiento queda firmado con tu clave y que la gente lee su historial.

Petición

POST /api/v1/users/7/wallet/topup
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{ "monto": 20000,
  "concepto": "Recarga en ventanilla" }

200 OK

{
  "operacion": "recarga",
  "monto": 20000,
  "saldo": 67050,
  "usuario_id": 7
}

Cuando falla

CódigoerrorCuándo
401clave_requerida · clave_invalidaSin clave viva no se mueve dinero.
404usuario_no_encontradoEse id no existe.
400 · 409 · 403monto_… · supera_saldo_maximo · usuario_inactivoLos mismos de cualquier recarga.
POST

/users/:id/wallet/charge

clave

El cobro del torniquete: descuentas saldo con tu clave, sin que la persona inicie sesión.

Es el endpoint que cierra el caso completo — lees el uid con GET /cards/:uid, sacas el usuario.id y cobras aquí. La tarifa la pone tu proyecto; la API solo comprueba que el número sea un número y que haya saldo.

Funciona sobre cualquier usuario, no solo sobre los que registró tu clave: eso es lo que hace que la tarjeta de alguien sirva en el torniquete de un proyecto y en la cafetería de otro. Y por lo mismo, cobrar es la operación más fácil de fastidiarle a un compañero sin querer — hazlo con tus usuarios de prueba.

Petición

POST /api/v1/users/7/wallet/charge
X-API-Key: tk_9fK2xQm…
Content-Type: application/json

{ "monto": 1500,
  "concepto": "Pasaje · estudiante" }

200 OK

{
  "operacion": "cobro",
  "monto": 1500,
  "saldo": 65550,
  "usuario_id": 7
}

Cuando falla

CódigoerrorCuándo
402saldo_insuficienteTrae saldo y falta. Es una respuesta esperada, no un fallo.
404usuario_no_encontradoEse id no existe.
403usuario_inactivoLa cuenta está bloqueada.
400monto_invalido · monto_fuera_de_rangoRevisa que sea entero y esté en rango.

Claves · profesor

De dónde salen las claves

Esta parte no es para los proyectos: pide X-Admin-Token, que solo tiene quien administra el servidor. Está documentada porque entender de dónde sale tu clave —y qué puede hacer el profesor con ella— explica media API. Si eres estudiante, lo tuyo es GET /key.

El camino normal no es HTTP sino la máquina donde vive la base:

npm run clave -- "Nombre Apellido"   # crea una y la imprime, una sola vez
npm run clave                        # lista las que hay, con su uso

De la clave solo se guarda su hash. Si un estudiante la pierde no hay forma de recuperarla: se le crea otra y se revoca la anterior. Sin TOKEN_ADMIN en el entorno del servidor, todo lo de abajo responde 401 a todo el mundo, también desde localhost.

POST

/keys

profesor

Crea una clave para un estudiante. Es la única vez que se ve entera.

Cuerpo

CampoTipoQué es
estudiante obligatorio texto De quién es. Dos caracteres o más; sale en la lista y en los registros.
limite_usuarios entero Cuántos puede registrar antes de 409 cuota_agotada. Por defecto, 200.

Petición

POST /api/v1/keys
X-Admin-Token: …
Content-Type: application/json

{ "estudiante": "Ana Ruiz",
  "limite_usuarios": 200 }

201 Created

{
  "id": 4,
  "clave": "tk_9fK2xQm7vN3pR8sT1uW5yZ0aB2cD4eF",
  "prefijo": "tk_9fK2xQm",
  "estudiante": "Ana Ruiz",
  "limite_usuarios": 200,
  "aviso": "Guárdala: no se puede volver a consultar."
}

Cuando falla

CódigoerrorCuándo
401token_admin_invalidoFalta el X-Admin-Token, no coincide, o el servidor no tiene ninguno configurado.
400estudiante_requeridoMenos de dos caracteres.
400limite_invalidoNo es un entero de 1 para arriba.
GET

/keys

profesor

Las claves del curso con su uso: cuántos usuarios lleva cada una y si sigue viva.

Petición

GET /api/v1/keys
X-Admin-Token: …

200 OK

{
  "claves": [
    {
      "id": 4,
      "prefijo": "tk_9fK2xQm",
      "estudiante": "Ana Ruiz",
      "activa": true,
      "sistema": false,
      "limite_usuarios": 200,
      "usuarios": 14,
      "creada": "2026-07-20T15:02:00.000Z",
      "revocada": null
    }
  ]
}

La clave entera no vuelve a aparecer: solo el prefijo, que sirve para reconocerla sin poder usarla. sistema: true marca la del terminal del metro, que no es de nadie y no se puede revocar.

DELETE

/keys/:id

profesor

Revoca una clave y, si se pide, borra todo lo que creó.

Revocar deja la clave sin efecto y no toca los datos: es lo normal al cerrar el semestre, que los proyectos se queden como estaban. Purgar —con ?purgar=true— borra además los usuarios que registró y, en cascada, sus tarjetas, movimientos y sesiones; es para quien la usó para llenar la base de basura.

Lo que esa clave hizo sobre usuarios de otros —una recarga a alguien que entró desde su proyecto— se queda. Es dinero de esa persona, y borrarlo le dejaría un saldo sin historia que lo explique.

Revocar

DELETE /api/v1/keys/4
X-Admin-Token: …

{ "revocada": true, "purgada": false }

Revocar y purgar

DELETE /api/v1/keys/4?purgar=true
X-Admin-Token: …

{
  "revocada": true,
  "purgada": true,
  "borrado": {
    "movimientos": 812,
    "tarjetas": 31,
    "sesiones": 44,
    "usuarios": 26
  }
}

Cuando falla

CódigoerrorCuándo
404clave_no_encontradaEse id no está.
403clave_de_sistemaEs la del terminal del metro: revocarla dejaría las tarjetas sin firmar.

Lo que el token de administrador se salta

Además de estas tres rutas, X-Admin-Token sirve como comodín en toda la API: pasa por donde se pide clave, se salta la regla de propiedad —edita o borra usuarios y tarjetas de cualquiera— y es el único que ve las tarjetas de administrador, en la lista y por uid. Por eso no se reparte: no es una clave más con más cuota, es la que no tiene reglas.

Errores

Todos los códigos, en una tabla

Lo que devuelve la API cuando algo va mal, junto. Los de arriba pueden salir en cualquier ruta; los de abajo, solo donde dice su ficha.

Qué significa cada código HTTP

CódigoQué quiere decir
200 · 201 · 204Salió bien. 204 no trae cuerpo.
400El cuerpo o la consulta traen algo mal. Revisa el campo error.
401Falta una credencial o no vale. Lleva WWW-Authenticate: Bearer cuando el problema es el token.
402No hay saldo. Solo lo devuelve un cobro.
403La credencial es buena, pero eso no es tuyo o la cuenta está bloqueada.
404No existe — o no existe para ti, como las tarjetas de administrador.
409Choca con algo que ya está: email repetido, uid en uso, cuota agotada, tope de saldo.
429Demasiadas peticiones o demasiados intentos de login. Mira Retry-After.
500Se rompió el servidor. Si te sale, avisa: es un fallo nuestro, no tuyo.

Los que pueden salir en cualquier ruta

CódigoerrorQué hacer
400json_invalidoEl cuerpo no es JSON. Casi siempre falta el Content-Type o sobra una coma.
400cuerpo_muy_grandeMás de 4 kB. Aquí no se suben archivos.
401clave_requeridaManda X-API-Key.
401clave_invalidaEsa clave no existe o la revocaron. Comprueba con GET /key.
401token_requeridoFalta Authorization: Bearer ….
401token_invalidoNo se puede verificar: mal copiado, de otro servidor, o el usuario ya no está.
401token_expiradoPasaron los 15 minutos: refresca y reintenta.
401token_caducado_por_cambioEsa persona cambió su contraseña. Hay que volver a iniciar sesión.
403no_es_tuyoLo creó otra clave: se puede leer, y moverle el saldo, pero no modificarlo ni borrarlo.
403usuario_inactivoLa cuenta está bloqueada: ni entra ni se le mueve el saldo.
404ruta_desconocidaEsa ruta no existe bajo /api/v1.
404no_encontradoEl genérico de las rutas que exigen ser dueño: ese usuario o esa tarjeta no está.
429demasiadas_peticionesPasaste de 120 por minuto. Trae limite y ventana; espera 60 s.
500error_internoExcepción no prevista en el servidor.

Los 404 tienen dos redacciones y no es descuido: usuario_no_encontrado y tarjeta_no_registrada salen de las rutas de lectura, mientras que las que exigen ser dueño responden el genérico no_encontrado. Si contestaran distinto según de quién sea la fila, ese 404 pasaría a ser una forma de averiguar qué existe.

Recetas

Tres flujos enteros

Endpoints sueltos ya están arriba. Esto es cómo se encadenan en lo que de verdad monta la gente.

1 · De cero a un saldo que se mueve

Para probar que tu clave funciona y dejar un usuario con saldo con el que trastear. En la terminal:

API=https://nfc.multimediaudeb.com/api/v1
CLAVE=tk_…    # la tuya

# 1. Registrar a alguien
RESP=$(curl -s -X POST $API/auth/register \
  -H "X-API-Key: $CLAVE" -H 'Content-Type: application/json' \
  -d '{"email":"ana@ejemplo.com","contrasena":"tunja2026","nombre":"Ana Ruiz","perfil":"estudiante"}')

ACCESO=$(echo "$RESP" | jq -r .acceso)
ID=$(echo "$RESP" | jq -r .usuario.id)

# 2. Recargarle la billetera
curl -s -X POST $API/wallet/topup \
  -H "X-API-Key: $CLAVE" -H "Authorization: Bearer $ACCESO" \
  -H 'Content-Type: application/json' -d '{"monto":50000,"concepto":"Recarga"}'

# 3. Darle una tarjeta
curl -s -X POST $API/cards \
  -H "X-API-Key: $CLAVE" -H 'Content-Type: application/json' \
  -d "{\"uid\":\"04A1B2C3\",\"usuario_id\":$ID}"

# 4. Cobrarle algo (el precio lo pones tú)
curl -s -X POST $API/wallet/charge \
  -H "X-API-Key: $CLAVE" -H "Authorization: Bearer $ACCESO" \
  -H 'Content-Type: application/json' -d '{"monto":2950,"concepto":"Pasaje"}'

# 5. Ver cómo quedó
curl -s $API/wallet -H "Authorization: Bearer $ACCESO"

Lo mismo como script de Node, para dejarlo en el repositorio y repetirlo:

// sembrar.js — node --env-file=.env sembrar.js
import { tunja } from "./api.js";

const { usuario, acceso } = await tunja("/auth/register", {
  metodo: "POST",
  cuerpo: {
    email: `prueba${Date.now()}@ejemplo.com`,   // único en cada corrida
    contrasena: "tunja2026",
    nombre: "Ana Ruiz",
    perfil: "estudiante",
  },
});

await tunja("/wallet/topup", {
  metodo: "POST",
  token: acceso,
  cuerpo: { monto: 50000, concepto: "Recarga inicial" },
});

await tunja("/cards", {
  metodo: "POST",
  cuerpo: { uid: "04A1B2C3", usuario_id: usuario.id },
});

const billetera = await tunja("/wallet", { token: acceso });
console.log(`Usuario ${usuario.id} listo con ${billetera.saldo} ${billetera.moneda}`);

El email tiene que ser distinto en cada corrida: la base es única y compartida, así que ana@ejemplo.com lo cogió alguien hace tres semanas. Un Date.now() dentro del email evita el 409 email_en_uso eterno. Y borra tus usuarios de prueba cuando termines: la cuota son 200.

2 · Leer la tarjeta en el celular y cobrar

La otra mitad del torniquete —el servidor está más arriba—: un teléfono Android con Chrome lee el uid por Web NFC y se lo manda a tu Express. No hace falta lector USB ni aplicación: el carné estudiantil suelta su uid tal cual.

// En el navegador. Web NFC solo existe en Chrome sobre Android y pide HTTPS.
const boton = document.getElementById("activar");

boton.addEventListener("click", async () => {
  if (!("NDEFReader" in window)) return alert("Este navegador no lee NFC");

  const lector = new NDEFReader();
  await lector.scan();          // pide permiso: tiene que salir de un clic

  lector.addEventListener("reading", async ({ serialNumber }) => {
    // Android lo entrega como "04:1a:2b:3c" y la API lo quiere en hex plano.
    const uid = (serialNumber ?? "").replace(/[^0-9a-fA-F]/g, "").toUpperCase();
    if (uid.length < 4) return;

    // A TU servidor, que es el que tiene la clave.
    const res = await fetch("/pasar", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uid }),
    });

    const veredicto = await res.json();
    pintar(veredicto);          // { paso: true, monto: 1500, saldo: 45550 }
  });
});

Mientras no tengas tarjetas a mano, tu Express puede recibir el uid de un <input> escrito a mano: 04A1B2C3 vale igual que uno leído. El NFC es la última pieza que hay que enchufar, no la primera.

3 · Un cliente que refresca solo

El acceso dura 15 minutos, así que cualquier sesión que dure más de eso necesita esto. La regla: un 401, un refresco, un reintento — y si el refresco tampoco vale, al login.

const API = "https://nfc.multimediaudeb.com/api/v1";

let acceso = localStorage.getItem("acceso");
let refresco = localStorage.getItem("refresco");

function guardar(par) {
  acceso = par.acceso;
  refresco = par.refresco;              // el anterior ya no vale: se pisa
  localStorage.setItem("acceso", acceso);
  localStorage.setItem("refresco", refresco);
}

async function llamar(camino, opciones = {}, reintento = true) {
  const respuesta = await fetch(API + camino, {
    ...opciones,
    headers: {
      "Content-Type": "application/json",
      ...(acceso && { Authorization: `Bearer ${acceso}` }),
      ...opciones.headers,
    },
  });

  if (respuesta.status === 401 && reintento && refresco) {
    const nueva = await fetch(`${API}/auth/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refresco }),
    });

    if (!nueva.ok) {          // el refresco tampoco vale: a iniciar sesión otra vez
      localStorage.clear();
      throw new Error("sesion_caducada");
    }

    guardar(await nueva.json());
    return llamar(camino, opciones, false);
  }

  return respuesta.json();
}

Fíjate en que la clave no aparece: este código corre en el navegador, y ahí no se escribe un secreto. El login y las lecturas van directas a la API —el CORS está abierto para eso—; lo que pide clave pasa por tu Express, que es el único sitio donde el secreto está a salvo. Es una API de laboratorio y nadie va a perder dinero de verdad, pero conviene saber cuál de las dos cosas estás haciendo.