API HTTP¶
El backend expone dos superficies HTTP bien separadas, sobre el mismo jar: la API pública que consume la landing (anónima, sin login) y la API del gestor (autenticada, para el operador de Caracol). Esta página documenta ambas — método, ruta, body y respuesta — tal como están implementadas hoy.
Convenciones generales
- JSON en
snake_case, siempre —spring.jackson.property-naming-strategy: SNAKE_CASEestá fijado globalmente (application.yml). Un campo JavapremioUnidadIdviaja comopremio_unidad_iden el body y en la respuesta. - Respuestas de error genéricas hacia el cliente público: la API de participación nunca
revela el motivo real de un rechazo (firma inválida, ya usado, etc.) — ese detalle queda
solo en el registro interno (
voucher_rechazado) y en logs. Ver Errores más abajo. - Todas las rutas bajo
/api/gestor/**exigen HTTP Basic (usuario/password por variable de entorno). El resto de la API (/api/participacion/**,/api/padron/**,/bases/**,/api/campania/vigente,/api/salud,/api/branding) es pública (permitAll).
API pública (landing)¶
Cubre los cinco resultados de escanear un QR (Casos A–E) más el alta al padrón fuera de campaña. Para el detalle del flujo de negocio detrás de cada caso, ver Casos de participación (A–E).
GET /p/{token}¶
Sirve el HTML de la landing (Preact) con el estado inicial embebido en un
<script id="sp-estado" type="application/json">: {token, campania, resolucion} — el mismo
payload que hoy resolverían GET /api/campania/vigente + POST /api/participacion/resolver (sin
dni), pero sin los dos viajes de red adicionales antes del primer render (importante en 3G de
salón). Si algo falla al resolver cualquiera de los dos campos, cae a null — nunca rompe la
página, y el frontend cae al camino de fetch de siempre.
POST /api/participacion/resolver¶
Decodifica el token (validación canónica del Anexo A) y resuelve a qué caso corresponde, sin consumir el voucher. Es de lectura — se puede llamar más de una vez sobre el mismo token sin efecto.
dni es opcional. Sin él, un token con firma válida y vigente resuelve al estado intermedio
PENDIENTE_DNI (la Pantalla 1a recién ahí pide el DNI). Con dni presente, la resolución avanza
hasta D, E o TOPE.
// response — ejemplo Caso E (DNI ya registrado)
{
"caso": "E",
"mensaje": "Participación confirmada.",
"ticket": {
"nro_ticket": 45678,
"nro_sucursal": 4,
"fecha_emision": "2026-08-19T14:32:00"
},
"cliente": { "nombre": "Juan", "dni_mascarado": "***23456" },
"chances_acumuladas": 3,
"datos_origen": "manual",
"email_mascarado": null,
"celular_mascarado": "***1234"
}
caso |
Significa | Pantalla |
|---|---|---|
A |
Firma inválida | Error genérico, sin pistas |
B |
Token ya usado | "Ya participó" + fecha_registro_original |
C |
Fuera de vigencia por fecha de emisión | Alta a padrón sin chance (Pantalla 4) |
PENDIENTE_DNI |
Vigente, sin dni en el request |
Pide DNI (Pantalla 1a) |
D |
Vigente + DNI nuevo | Requiere POST /alta |
E |
Vigente + DNI ya en el padrón | Requiere POST /confirmar |
TOPE |
DNI ya alcanzó el tope diario de chances | Rechazo sin consumir el voucher |
ParticipacionResponse es la forma única que comparten resolver/confirmar/alta/
padron/alta: cada campo viaja solo si aplica al caso (@JsonInclude(NON_NULL)).
POST /api/participacion/confirmar¶
Caso E: el DNI ya está en el padrón. Consume la chance — es idempotente (un retry sobre el mismo token nunca duplica la chance; resuelve a Caso B).
// request — con corrección/completado de datos (ADR-028)
{
"token": "...",
"dni": "30123456",
"cambios": {
"email": "juan@mail.com",
"celular": "3511234567",
"nombre": null,
"apellido": null,
"sexo": null,
"fecha_nacimiento": null
},
"datos_de_escaneo": false
}
cambios es opcional y cada campo dentro de él también lo es — solo se aplica lo no nulo. El
celular/email siempre son editables presentando el DNI; nombre/apellido/sexo/fecha de
nacimiento solo si cliente.datos_origen = 'manual' (ver modelo de datos).
Intentar editar identidad de un cliente datos_origen = 'escaneo' responde 400 genérico
(nunca expone la regla al cliente).
POST /api/participacion/alta¶
Caso D: DNI nuevo. Alta de cliente + consumo de la chance, transaccional.
// request
{
"token": "...",
"dni": "30123456",
"nombre": "Juan",
"apellido": "Pérez",
"celular": "3511234567",
"fecha_nacimiento": "1990-05-10",
"sexo": "M",
"email": "juan@mail.com",
"datos_de_escaneo": false
}
celular se normaliza a 10 dígitos automáticamente (se descarta todo lo que no sea número:
espacios, guiones, +54). El request incluye además un campo honeypot invisible
(contacto_web), documentado más abajo en Antifraude.
POST /api/padron/alta¶
Caso C (Pantalla 4a): el voucher es válido pero está fuera de la vigencia de la campaña por fecha de emisión. No se registra chance — solo alta al padrón para próximas promociones.
// request
{
"token": "...",
"dni": "30123456",
"nombre": "Juan",
"apellido": "Pérez",
"celular": "3511234567"
}
GET /api/campania/vigente¶
Datos públicos de la campaña vigente que la landing necesita para pintarse (nombre, descripción,
bases, vigencia) más la identidad visual del cliente (cliente_nombre, color_primario,
logo_url — los mismos valores que GET /api/branding) y el flag reconocimiento_habilitado.
404 si no hay ninguna campaña vigente (la base de datos garantiza a lo sumo una).
// response
{
"nombre": "Sorteo 50 años",
"descripcion": "...",
"bases_url": "/bases/v1.pdf",
"version_bases": "v1",
"vigencia_desde": "2026-09-01T00:00:00",
"vigencia_hasta": "2026-10-09T23:59:00",
"cliente_nombre": "Supermercados Caracol",
"color_primario": "#...",
"logo_url": "/branding/logo.png",
"reconocimiento_habilitado": true
}
Reconocimiento de dispositivo (extensión, ADR-027)¶
Tres endpoints adicionales permiten que la landing "recuerde" un dispositivo ya usado para saltar el tipeo del DNI en visitas siguientes — sin persistir el DNI en el navegador (el DNI nunca baja al cliente; solo un secreto opaco).
| Método | Ruta | Para qué |
|---|---|---|
| POST | /api/participacion/reconocer |
{secreto} → {encontrado, cliente?}. encontrado=false es una respuesta válida (nunca error) — instruye a caer al alta por DNI de siempre |
| POST | /api/participacion/confirmar-reconocido |
{token, secreto} — variante de confirmar sin DNI: el cliente se resuelve server-side desde el secreto |
| — | — | Al confirmar/dar de alta con éxito (D/E), la respuesta incluye secreto_reconocimiento una sola vez — el frontend lo guarda para la próxima visita |
Antifraude¶
- Honeypot invisible:
AltaRequestyPadronAltaRequestaceptan un campo opcionalcontacto_web, que el frontend renderiza oculto (display:none, sin label). Un humano nunca lo completa; un bot que llena todos los campos sí. Si llega no vacío, la respuesta es idéntica a Caso A (sin pistas) y se registra envoucher_rechazadocon motivobot— nunca crea cliente ni chance. - Rate limit por conexión sobre
/api/*y/p/*. - Tope diario de chances por DNI (parámetro de campaña, default 5) — rechazo sin consumir el
voucher, caso
TOPE.
Errores y códigos de estado¶
| Situación | HTTP | Body |
|---|---|---|
Validación de campos (@Valid) |
400 |
{"mensaje": "Datos invalidos.", "errores": {"campo": "detalle"}} |
| Método no soportado | 405 |
{"mensaje": "Metodo no soportado."} |
| Asset estático inexistente | 404 |
{"mensaje": "No encontrado."} |
| Edición de identidad no permitida (ADR-028) | 400 |
{"mensaje": "No pudimos guardar esos datos."} |
Conflicto de concurrencia (@Version, edición simultánea) |
409 |
{"mensaje": "otra operacion modifico tus datos; volve a intentar"} |
| Cualquier otro error interno | 500 |
{"mensaje": "Error procesando la solicitud"} (detalle real solo en logs) |
API del gestor (autenticada)¶
Todo bajo /api/gestor/**, protegido con HTTP Basic — usuario/password fijados por variable
de entorno (SP_GESTOR_USER, SP_GESTOR_PASSWORD; el arranque falla rápido si falta la
password, nunca genera una al vuelo). A diferencia de la API pública, los errores de negocio acá
sí devuelven el motivo real (staff autenticado) — ver Errores del gestor.
Campañas¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/campanias/vigente |
Campaña vigente (404 si no hay ninguna) |
| GET | /api/gestor/campanias/{id} |
Campaña por id |
| POST | /api/gestor/campanias |
Alta de campaña |
| PUT | /api/gestor/campanias/{id} |
Edición |
| POST | /api/gestor/campanias/{id}/bases |
Sube el PDF de bases (multipart, archivo + version opcional) — versiona automáticamente, valida firma de bytes PDF y tamaño (≤10 MB), nunca sobrescribe una versión existente (inmutable, ADR-007) |
| GET | /api/gestor/campanias/{id}/sorteos |
Lista los sorteos de la campaña |
| POST | /api/gestor/campanias/{id}/sorteos/generar |
Genera la tanda completa de sorteos (semanales + cierre) |
| POST | /api/gestor/campanias/{id}/sorteos |
Agrega un sorteo suelto a una campaña existente |
| POST | /api/gestor/campanias/{id}/premios/repartir |
Reparte automáticamente el stock disponible en partes iguales entre sorteos, como punto de partida |
// POST /api/gestor/campanias — request
{
"nombre": "Sorteo 50 años",
"descripcion": "...",
"promo_id": 1,
"estado": "vigente",
"vigencia_desde": "2026-09-01T00:00:00",
"vigencia_hasta": "2026-10-09T23:59:00",
"regla_chances": 1,
"tope_chances_dni_dia": 5,
"plazo_retiro_dias": 10,
"key_id_vigente": 1
}
// POST /api/gestor/campanias/{id}/sorteos/generar — request
{ "frecuencia": "semanal", "dia_hora": "MONDAY 10:00" }
Premios¶
| Método | Ruta | Para qué |
|---|---|---|
| POST | /api/gestor/premios/tipos |
Carga un premio_tipo ({campania_id, descripcion, valor_unitario, cantidad}) |
| GET | /api/gestor/premios/tipos?campania_id= |
Lista tipos con stock_disponible calculado |
| GET | /api/gestor/premios/unidades?campania_id=&estado= |
Lista unidades por estado (paginado, hasta 500) |
| POST | /api/gestor/sorteos/{id}/premios |
Asigna una unidad a un sorteo, con prelación |
| GET | /api/gestor/sorteos/{id}/asignaciones |
Lista las asignaciones de un sorteo |
| POST | /api/gestor/asignaciones/{id}/desasignar |
Desasigna ({motivo}), body obligatorio — se cambió de DELETE a POST porque un DELETE con body no tiene semántica garantizada en proxies/gateways |
Sorteos¶
| Método | Ruta | Para qué |
|---|---|---|
| PUT | /api/gestor/sorteos/{id} |
Edita nombre / fecha_hora_publicada / suplentes_a_sortear de un sorteo pendiente (rechaza si ya está ejecutado; la fecha nueva no puede romper el orden monótono respecto al sorteo anterior/siguiente) |
| POST | /api/gestor/sorteos/{id}/ejecutar |
Dispara el sorteo — siempre manual (ADR-009). Genera el acta |
// POST /api/gestor/sorteos/{id}/ejecutar — response
{
"acta_id": 12,
"sorteo_id": 3,
"fecha_ejecucion": "2026-09-07T10:00:05",
"cantidad_participantes": 4231,
"hash_lista": "e3b0c4..."
}
Actas¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/actas/{id} |
Detalle completo: sección inmutable (lo sorteado) + snapshot de estado de entrega |
| GET | /api/gestor/actas/{id}/resultados |
Lista de resultados, cada uno con plazo_vencido/dias_desde_contacto calculados |
| GET | /api/gestor/actas/{id}/export.json |
Igual a ver, pero con Content-Disposition: attachment |
| GET | /api/gestor/actas/{id}/export.csv |
Export CSV: bloque de metadata del acta + tabla inmutable de resultados + tabla snapshot de estado de entrega |
La respuesta separa siempre dos secciones: acta (inmutable — premio originalmente sorteado,
premio_unidad_id_original) y estado_entrega (snapshot al momento de la consulta —
premio_unidad_id actual, que muta con las operaciones de ganadores).
Ver El motor de sorteo y el acta para el porqué de esta
separación.
Ganadores y entregas¶
| Método | Ruta | Para qué |
|---|---|---|
| POST | /api/gestor/resultados/{id}/contactos |
Registra un intento de contacto ({canal, resultado}) — pasa el resultado de pend_contacto a contactado si era el primero |
| POST | /api/gestor/resultados/{id}/entrega |
Registra la entrega física ({sucursal, dni_verificado, confirmar_vencido}) — verifica el DNI contra el acta; si el plazo de retiro venció, exige confirmar_vencido: true (409 sin él) |
| POST | /api/gestor/resultados/{id}/pasar-suplente |
Pasa al siguiente suplente de la misma acta ({motivo}) |
| POST | /api/gestor/unidades/{id}/devolver |
Devuelve la unidad al stock ({motivo}) — habilita reasignarla a un sorteo posterior |
| POST | /api/gestor/unidades/{id}/no-otorgado |
Marca la unidad como NO_OTORGADO, estado terminal ({motivo}) |
// POST /api/gestor/resultados/{id}/entrega — request
{ "sucursal": "Sucursal Centro", "dni_verificado": "30123456", "confirmar_vencido": false }
Las cuatro operaciones que mutan premio_unidad (desasignar, pasar a suplente, devolver,
no-otorgado) comparten el mismo contrato: {"motivo": "..."}, obligatorio — cada cambio queda
auditado en evento_premio con usuario y motivo.
Clientes¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/clientes?q=&page=&size= |
Búsqueda por DNI/apellido/celular. q obligatorio, mínimo 2 caracteres (nunca devuelve el padrón completo sin filtro); paginado server-side, size tope 200 |
| GET | /api/gestor/clientes/{id} |
Detalle + historial de chances del cliente |
| PUT | /api/gestor/clientes/{id} |
Corrección de nombre/apellido/celular/email (solo los campos presentes se aplican) |
Chances y vouchers¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/chances?sucursal=&caja=&fecha=&top= |
Rastreo de chances con filtros opcionales; top acotado (default 200, máx. 1000) |
| GET | /api/gestor/vouchers/{hash} |
Trazabilidad de un voucher por su hash_token: la chance que registró (si existe) + rechazos previos con ese mismo hash |
Claves de firma¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/claves |
Lista metadatos de claves — nunca el secreto, ni siquiera cifrado |
| POST | /api/gestor/claves |
Alta ({key_id, secreto}) — el secreto en claro solo viaja en este body, se cifra antes de persistir |
| POST | /api/gestor/claves/{keyId}/revocar |
Revoca ({motivo}) — 409 si ya está revocada o si es la clave vigente de la campaña vigente (hay que rotar la campaña primero) |
Ver ClaveFirma en el modelo de datos y rotación de claves en el Anexo A.
Modo prueba¶
Solo existe si sp.modo-prueba.enabled=true (el bean ni se registra si el flag está apagado —
la ruta responde 404, nunca 403, para que la UI distinga "no habilitado" de "sin permiso").
Nunca habilitable en producción.
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/modo-prueba/estado |
{"enabled": true} — la UI lo usa para decidir si muestra el banner |
| POST | /api/gestor/modo-prueba/vouchers |
Genera N vouchers de prueba firmados ({campania_id, nro_sucursal, nro_pos, fecha_emision, cantidad}) |
| GET | /api/gestor/modo-prueba/vouchers?campania_id= |
Auditoría de vouchers de prueba generados |
Panel¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/panel |
Métricas de un vistazo: participación, stock, señales antifraude |
Export CSV¶
| Método | Ruta | Para qué |
|---|---|---|
| GET | /api/gestor/export/padron.csv |
Padrón completo: dni,nombre,apellido,celular,email,origen,creado_en |
| GET | /api/gestor/export/pendientes-contacto.csv |
Ganadores/suplentes pendientes de contacto, con celular ya normalizado a 10 dígitos |
Errores del gestor¶
A diferencia de la API pública, acá el operador sí recibe el motivo real de un error de negocio — es staff autenticado, no el cliente final.
| Situación | HTTP | Body |
|---|---|---|
Argumento inválido (IllegalArgumentException) |
400 |
{"mensaje": "<motivo real>"} |
Estado inválido (IllegalStateException) — ej. sorteo ya ejecutado, clave ya revocada |
409 |
{"mensaje": "<motivo real>"} |
Conflicto de concurrencia (@Version o deadlock detectado) |
409 |
{"mensaje": "otra operacion modifico el premio; reintenta"} |
| Violación de integridad (UNIQUE/CHECK/FK) | 409 |
{"mensaje": "conflicto de integridad de datos"} (sin nombrar una causa específica que puede no aplicar) |
| Archivo de bases demasiado grande | 413 |
{"mensaje": "el archivo supera el tamano maximo permitido (10 MB)"} |
Ver también¶
- Modelo de datos — las tablas detrás de cada recurso.
- El token del QR (Anexo A) — el
tokenque recibenresolver/confirmar/alta/padron/alta. - Casos de participación (A–E) — el flujo de negocio completo detrás de la API pública.
- El motor de sorteo y el acta — el flujo detrás de
ejecutary las actas.