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ámetro | Tipo | Qué 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
|
sí |
sí |
sí |
email, documento, saldo,
actualizado, sin_credenciales
|
no |
de todos |
solo los suyos |
uid de una tarjeta en una lista |
no |
sí |
no |
| Crear usuarios y tarjetas |
no |
sí |
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.
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
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ímite | Valor | Qué 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 |
100 – 500 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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 401 | clave_requerida | No llegó la cabecera X-API-Key. |
| 401 | clave_invalida | Llegó, pero no es una clave viva. |
| 400 | email_invalido | Falta la arroba, el punto, o el campo entero. |
| 400 | contrasena_debil | Trae motivo y minimo: corta, obvia o igual al email. |
| 400 | nombre_requerido | Menos de dos caracteres. |
| 400 | perfil_desconocido | Trae validos con la lista. |
| 400 | descuento_invalido | No es entero o se sale de 0–100. |
| 409 | email_en_uso | Ya hay alguien con ese email en la base compartida. |
| 409 | cuota_agotada | Tu clave llegó a su tope de usuarios. Trae limite. |
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
| Campo | Tipo | Qué 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ódigo | error | Cuá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.
|
| 403 | usuario_inactivo | La 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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 401 | refresco_invalido | No 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.
|
| 401 | refresco_revocado | Se cerró esa sesión, o se cerraron todas al cambiar la contraseña. |
| 401 | refresco_expirado | Pasaron los 30 días. |
| 401 | usuario_inactivo | La 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)
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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 403 | contrasena_actual_incorrecta | actual no coincide. |
| 400 | contrasena_debil | La nueva no pasa las reglas. Trae motivo. |
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…
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ámetro | Tipo | Qué 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ódigo | error | Cuándo |
| 400 | perfil_desconocido | El perfil no es uno de los cuatro. Trae validos. |
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
}
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
| Campo | Tipo | Qué es |
nombre | texto | Dos caracteres o más. Ojo: las iniciales no se recalculan. |
documento | texto | Libre. 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ódigo | error | Cuándo |
| 400 | nombre_requerido | Mandaste 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ódigo | error | Cuándo |
| 404 | usuario_no_encontrado | Ese 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ódigo | error | Cuándo |
| 404 | usuario_no_encontrado | Ese 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
| Campo | Tipo | Qué es |
nombre | texto | Dos caracteres o más. |
documento | texto | Libre. |
perfil | enum | Uno de los cuatro de /config. |
descuento | entero | 0 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ódigo | error | Cuándo |
| 401 | clave_requerida · clave_invalida | Sin clave viva no se escribe. |
| 404 | no_encontrado | Ese id no existe. Es el genérico de las rutas de dueño. |
| 403 | no_es_tuyo | Ese usuario lo creó otra clave. |
| 400 | nombre_requerido · perfil_desconocido · descuento_invalido | Los 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ódigo | error | Cuándo |
| 404 | no_encontrado | Ese id no existe. |
| 403 | no_es_tuyo | Lo 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ámetro | Tipo | Qué hace |
limite | entero | 1–200, por defecto 50. |
desde | entero | Cuá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ódigo | error | Cuá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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 401 | clave_requerida · clave_invalida | Sin clave viva no se escribe. |
| 400 | uid_invalido | No es hexadecimal o no mide entre 4 y 32. Trae formato. |
| 404 | usuario_no_encontrado | Ese usuario_id no existe. |
| 403 | no_es_tuyo | El usuario existe pero lo registró otra clave. |
| 409 | uid_en_uso | Esa 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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 400 | activa_requerida | No mandaste activa. |
| 404 | no_encontrado | Ese uid no está vinculado —o es de administrador—. |
| 403 | no_es_tuyo | La 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ódigo | error | Cuándo |
| 404 | no_encontrado | Ese uid no está vinculado —o es de administrador—. |
| 403 | no_es_tuyo | La 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
| Campo | Tipo | Qué es |
id | entero | Identificador 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.
|
monto | entero | Siempre positivo: el signo lo dice operacion. |
saldo | entero | Cómo quedó después de este movimiento. |
concepto | texto · null | Lo 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.
|
fecha | ISO 8601 | Cuá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.
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ámetro | Tipo | Qué hace |
limite | entero | 1–200, por defecto 50. |
desde | entero | Cuá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
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 401 | token_requerido · token_expirado | Falta el token o ya caducó: refresca y reintenta. |
| 401 | clave_requerida · clave_invalida | El token solo no basta para escribir. |
| 400 | monto_invalido | No es un entero mayor que cero. |
| 400 | monto_fuera_de_rango | Trae minimo y maximo. |
| 409 | supera_saldo_maximo | El saldo resultante pasaría del tope. Trae saldo y saldoMaximo; no se recarga nada. |
| 403 | usuario_inactivo | La 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ódigo | error | Cuándo |
| 402 | saldo_insuficiente | Trae saldo y falta. No se descontó nada. |
| 400 | monto_invalido · monto_fuera_de_rango | Igual que en la recarga. |
| 403 | usuario_inactivo | La cuenta está bloqueada. |
| 401 | token_… · 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ámetro | Tipo | Qué hace |
limite | entero | Cuántos movimientos trae: 1–200, por defecto 50. |
desde | entero | Cuá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ódigo | error | Cuándo |
| 401 | clave_requerida · clave_invalida | Un saldo no se enseña sin credenciales. |
| 404 | usuario_no_encontrado | Ese 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ódigo | error | Cuándo |
| 401 | clave_requerida · clave_invalida | Sin clave viva no se mueve dinero. |
| 404 | usuario_no_encontrado | Ese id no existe. |
| 400 · 409 · 403 | monto_… · supera_saldo_maximo · usuario_inactivo | Los 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ódigo | error | Cuándo |
| 402 | saldo_insuficiente | Trae saldo y falta. Es una respuesta esperada, no un fallo. |
| 404 | usuario_no_encontrado | Ese id no existe. |
| 403 | usuario_inactivo | La cuenta está bloqueada. |
| 400 | monto_invalido · monto_fuera_de_rango | Revisa 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.
Crea una clave para un estudiante. Es la única vez que se ve entera.
Cuerpo
| Campo | Tipo | Qué 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ódigo | error | Cuándo |
| 401 | token_admin_invalido | Falta el X-Admin-Token, no coincide, o el servidor no tiene ninguno configurado. |
| 400 | estudiante_requerido | Menos de dos caracteres. |
| 400 | limite_invalido | No es un entero de 1 para arriba. |
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ódigo | error | Cuándo |
| 404 | clave_no_encontrada | Ese id no está. |
| 403 | clave_de_sistema | Es 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ódigo | Qué quiere decir |
| 200 · 201 · 204 | Salió bien. 204 no trae cuerpo. |
| 400 | El cuerpo o la consulta traen algo mal. Revisa el campo error. |
| 401 | Falta una credencial o no vale. Lleva WWW-Authenticate: Bearer cuando el problema es el token. |
| 402 | No hay saldo. Solo lo devuelve un cobro. |
| 403 | La credencial es buena, pero eso no es tuyo o la cuenta está bloqueada. |
| 404 | No existe — o no existe para ti, como las tarjetas de administrador. |
| 409 | Choca con algo que ya está: email repetido, uid en uso, cuota agotada, tope de saldo. |
| 429 | Demasiadas peticiones o demasiados intentos de login. Mira Retry-After. |
| 500 | Se rompió el servidor. Si te sale, avisa: es un fallo nuestro, no tuyo. |
Los que pueden salir en cualquier ruta
| Código | error | Qué hacer |
| 400 | json_invalido | El cuerpo no es JSON. Casi siempre falta el Content-Type o sobra una coma. |
| 400 | cuerpo_muy_grande | Más de 4 kB. Aquí no se suben archivos. |
| 401 | clave_requerida | Manda X-API-Key. |
| 401 | clave_invalida | Esa clave no existe o la revocaron. Comprueba con GET /key. |
| 401 | token_requerido | Falta Authorization: Bearer …. |
| 401 | token_invalido | No se puede verificar: mal copiado, de otro servidor, o el usuario ya no está. |
| 401 | token_expirado | Pasaron los 15 minutos: refresca y reintenta. |
| 401 | token_caducado_por_cambio | Esa persona cambió su contraseña. Hay que volver a iniciar sesión. |
| 403 | no_es_tuyo | Lo creó otra clave: se puede leer, y moverle el saldo, pero no modificarlo ni borrarlo. |
| 403 | usuario_inactivo | La cuenta está bloqueada: ni entra ni se le mueve el saldo. |
| 404 | ruta_desconocida | Esa ruta no existe bajo /api/v1. |
| 404 | no_encontrado | El genérico de las rutas que exigen ser dueño: ese usuario o esa tarjeta no está. |
| 429 | demasiadas_peticiones | Pasaste de 120 por minuto. Trae limite y ventana; espera 60 s. |
| 500 | error_interno | Excepció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.