Saltar a contenido

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_CASE está fijado globalmente (application.yml). Un campo Java premioUnidadId viaja como premio_unidad_id en 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.

// request
{
  "token": "AQAAAAEABAIA5gNoAACybvOHZD3fe037qgw",
  "dni": "30123456"
}

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 — confirmación simple
{ "token": "...", "dni": "30123456" }
// 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: AltaRequest y PadronAltaRequest aceptan un campo opcional contacto_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 en voucher_rechazado con motivo bot — 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
// POST /api/gestor/sorteos/{id}/premios — request
{ "premio_unidad_id": 501, "prelacion": 1 }

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)
// POST /api/gestor/claves — request
{ "key_id": 2, "secreto": "..." }

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