AVANT IT

AvantPOS · Manual de integración por API

Documento confidencial para socios de Avant IT

Versión del documento1.0
Fecha14/10/2026
Versión mínima de AvantPOS2.58.3 (16/09/2026)
Soportesoporte@avant-it.net · 93 694 05 02
Confidencial · Avant IT. Documento privado para socios que lo hayan solicitado. No publicar ni redistribuir sin autorización de Avant IT.

Índice

  1. Qué permite y qué no1
  2. Requisitos y alcance2
  3. Alta: generar la clave3
  4. Autenticación4
  5. Referencia de endpoints5
  6. Eventos en tiempo real (SSE)6
  7. Avisos al local (POST /avisos)7
  8. Tokens de un solo uso (acceso desde el backoffice)8
  9. Límites y buenas prácticas9
  10. Protección de datos10
  11. Versionado y compromiso de estabilidad11
  12. Ejemplos completos12

1. Qué permite y qué no

La API de integración de AvantPOS permite que un sistema externo del local (videovigilancia con IA, cartelería digital, business intelligence, un ERP, control de presencia…) consulte en tiempo real lo que ocurre en el punto de venta y avise al personal del local. Es una API de solo lectura, con una única excepción: el envío de avisos.

Lo que permite

Lo que NO permite

Si su proyecto necesita escribir en AvantPOS (por ejemplo, volcar pedidos de otra plataforma), no está cubierto por esta API: contacte con Avant IT en soporte@avant-it.net para valorarlo.

Módulo de licencia. El código de la API no exige ningún módulo de pago: con una clave válida, todos los endpoints responden. El campo licencia.modulos.camaras de GET /estado informa de si el local tiene contratado el módulo «Cámaras con IA», pero AvantPOS no bloquea la API por ello; si su servicio lo requiere, es usted quien decide qué hacer. Sí depende de ese módulo la pestaña «Cámaras» del backoffice (sección 8). No confunda este módulo con el módulo «Integración Glovo / Just Eat», que es otra cosa y no afecta a esta API.

2. Requisitos y alcance

Dónde funciona. La API se sirve dentro de la red del local, en el equipo donde corre AvantPOS (el servidor del local, al que se conectan TPV, comanderas y tablets). La dirección base es:

http://IP-del-TPV:8085/api/integracion

El puerto por defecto es el 8085, el de red local que deja configurado el instalador (que también crea la regla de firewall «AvantPOS LAN» en el equipo). La IP es la del equipo donde corre AvantPOS; el backoffice la muestra al final de la pestaña «Sistemas externos» (por ejemplo http://192.168.0.15:8085/api/integracion/eventos). Se recomienda reservar una IP fija o una reserva DHCP para ese equipo.

Dónde NO funciona

Sin cifrar. La API va por HTTP sin cifrar dentro de la red del local. Recomendaciones:

Un solo punto de acceso. Cada equipo con base de datos propia expone su propia API. Lo habitual (un servidor y comanderas) es un único punto de acceso.

3. Alta: generar la clave

La clave la genera el propio local (o Avant IT durante la puesta en marcha) desde el backoffice.

  1. Abra el backoffice de AvantPOS con un usuario administrador.
  2. Vaya a Configuración › Integraciones y abra la pestaña «Sistemas externos». (Hasta la versión 2.61.5 esta opción estaba en Sistema › Integraciones; el menú se rediseñó en la 2.61.6.)
  3. Pulse «Generar clave».
  4. Se muestra la clave completa una sola vez, con el formato avp_ seguido de 48 caracteres hexadecimales (52 caracteres en total). Pulse «Copiar» y entréguela al integrador por un canal seguro. Cuando salga de esa pantalla no se puede volver a ver: AvantPOS solo guarda un resumen irreversible (hash) y el prefijo (los 10 primeros caracteres) para que el local reconozca cuál es.
  5. La pantalla muestra además la fecha de creación y el último acceso de la clave actual, útil para comprobar que el sistema del integrador se está conectando.

Rotar la clave. Pulse «Generar clave nueva» (el botón cambia de nombre cuando ya hay una). El backoffice pide confirmación: la clave anterior deja de funcionar en el acto y cualquier sistema que la usara recibirá 401 hasta que se le configure la nueva. Los flujos SSE ya abiertos con la clave anterior no se cortan hasta que se reconecten, así que reinicie el cliente tras el cambio. No hay periodo de convivencia entre claves: coordine el cambio con el integrador.

Retirar la clave. Pulse «Quitar clave». La integración queda desactivada: todas las peticiones reciben 401. Se puede volver a activar generando una clave nueva (la anterior no se recupera).

Solo hay una clave por local. Si trabajan varios integradores en el mismo local, comparten la clave; en ese caso conviene que el local la gestione y la rote cuando termine la relación con alguno de ellos.

4. Autenticación

Todas las peticiones a /api/integracion/* deben llevar la clave en una de estas dos cabeceras:

Authorization: Bearer avp_0123456789abcdef0123456789abcdef0123456789abcdef
X-Api-Key: avp_0123456789abcdef0123456789abcdef0123456789abcdef

Si se envían las dos, tiene prioridad Authorization: Bearer. La clave no se admite en la URL (query string).

Errores

CódigoCuándoCuerpo
401Clave ausente, incorrecta, retirada, o integración sin clave generada{"error":"Clave de integración ausente o incorrecta.","codigo":"clave_integracion"}
401POST /tokens/validar con un token inválido, caducado o ya usadoSin cuerpo
400Datos no válidos en POST /avisos{"error":"El aviso necesita un título."} (el texto cambia según el caso, ver sección 7)
400Parámetro mal formado (por ejemplo desdeId=abc o una fecha ilegible) o JSON no válidoNormalmente sin cuerpo; en todo caso, sin el campo codigo
403La petición llega por el acceso remoto{"error":"Esta acción no está disponible en el acceso remoto.","codigo":"remoto_no_permitido"}
401La petición va a una instancia en la nube sin la clave de acceso del local{"error":"Introduce la clave de acceso del local.","codigo":"nube_acceso"}
500Error interno{"error":"Ha ocurrido un error inesperado. Vuelve a intentarlo; si sigue pasando, avisa a soporte."}

No existe el código 429. La API no limita las consultas por ritmo; el único límite es el de avisos (60 por hora, que se rechaza con 400). Un 402 no se produce en esta API.

La comprobación de la clave es en tiempo constante y AvantPOS anota el último acceso como mucho una vez por minuto.

5. Referencia de endpoints

Todas las rutas cuelgan de http://IP-del-TPV:8085/api/integracion. Los cuerpos de petición y respuesta son JSON en UTF-8 con nombres de campo en camelCase. Los ejemplos usan estas variables:

BASE="http://192.168.0.15:8085/api/integracion"
CLAVE="avp_0123456789abcdef0123456789abcdef0123456789abcdef"
MétodoRutaPara qué
GET/estadoComprobar la clave, el reloj y datos del local
GET/dispositivosPuntos de venta: TPV, comanderas y tablets
GET/usuariosUsuarios del local
GET/mesasEstado de la sala en este momento
GET/auditoriaHistorial de eventos, paginado por id
GET/eventosFlujo en tiempo real (SSE) con reanudación
POST/avisosEnviar un aviso al local
POST/tokens/validarValidar un token de un solo uso del backoffice

5.1 GET /estado

Sirve para comprobar que la clave funciona y para conocer la hora del equipo, el nombre del local y su zona horaria. Sin parámetros.

curl -s "$BASE/estado" -H "Authorization: Bearer $CLAVE"
{
  "ok": true,
  "version": "2.61.8",
  "machineId": "3F9A1C7E52B0D846",
  "ahoraUtc": "2026-10-14T09:12:33.4567891Z",
  "nombreComercial": "Bar La Esquina",
  "zonaHoraria": "Europe/Madrid",
  "licencia": {
    "modo": "Completa",
    "caducidad": "2027-03-31",
    "modulos": { "camaras": false }
  }
}
CampoSignificado
versionVersión de AvantPOS (formato 2.61.8)
machineIdIdentificador del equipo, el mismo de la licencia
ahoraUtcHora del equipo en UTC; compárela con la suya para detectar desfases de reloj
nombreComercialNombre comercial de la empresa configurada (puede ser null)
zonaHorariaZona horaria del equipo en formato IANA (por ejemplo Europe/Madrid)
licencia.modoDemo, Completa, Caducada o DemoCaducada
licencia.caducidadFecha yyyy-MM-dd, o null si no aplica
licencia.modulosnull en licencias anteriores a los módulos (sin restricciones); en otro caso {"camaras": true|false}

5.2 GET /dispositivos

Lista todos los puntos de venta, activos o no, ordenados por id. Sirve, por ejemplo, para asociar cada aparato con una cámara, una pantalla o una ubicación.

curl -s "$BASE/dispositivos" -H "Authorization: Bearer $CLAVE"
[
  { "id": 1, "nombre": "TPV 1", "tipo": "tpv", "activo": true, "cajaCobroId": null,
    "ip": "192.168.0.21", "ultimaVistaUtc": "2026-10-14T09:10:02.118Z" },
  { "id": 3, "nombre": "Comandera Sala", "tipo": "comandera", "activo": true, "cajaCobroId": 1,
    "ip": "192.168.0.34", "ultimaVistaUtc": "2026-10-14T09:11:47.502Z" }
]
CampoSignificado
idId del dispositivo. Es el que aparece como dispositivoId en los eventos
tipotpv, comandera o tablet
cajaCobroIdId de la caja a la que imputa sus ventas (una comandera vende en la caja de cobro de su TPV); null si no tiene
ipÚltima IP desde la que actuó el aparato (null si aún no se ha visto)
ultimaVistaUtcÚltima vez que se vio actividad del aparato (se anota como mucho una vez por minuto)

5.3 GET /usuarios

Lista todos los usuarios, activos o no, ordenados por id.

curl -s "$BASE/usuarios" -H "Authorization: Bearer $CLAVE"
[
  { "id": 1, "nombre": "Stefano", "rol": "Admin", "activo": true },
  { "id": 4, "nombre": "Marta", "rol": "Camarero", "activo": true }
]

rol es Camarero, Encargado o Admin. La API no devuelve PIN ni ningún otro dato personal que no sea el nombre.

5.4 GET /mesas

Estado de la sala en este instante: una entrada por mesa, incluidas las libres, ordenadas por sala y por id. Los campos de plano (forma, x, y, ancho, alto) son los mismos con los que el TPV dibuja la mesa.

curl -s "$BASE/mesas" -H "Authorization: Bearer $CLAVE"
[
  { "mesaId": 12, "mesa": "7", "salonId": 1, "salon": "Sala", "forma": "rect",
    "x": 320, "y": 140, "ancho": 100, "alto": 100, "comensales": 4,
    "ticketId": 214, "abiertoUtc": "2026-10-14T08:02:11.000Z", "cuentaPedida": false,
    "importeCents": 4850, "usuarioId": 4, "usuario": "Marta",
    "ultimaActividadUtc": "2026-10-14T08:40:07.000Z", "comensalesTicket": 4 },
  { "mesaId": 13, "mesa": "8", "salonId": 1, "salon": "Sala", "forma": "rect",
    "x": 460, "y": 140, "ancho": 100, "alto": 100, "comensales": 4,
    "ticketId": null, "abiertoUtc": null, "cuentaPedida": false,
    "importeCents": null, "usuarioId": null, "usuario": null,
    "ultimaActividadUtc": null, "comensalesTicket": null }
]
CampoSignificado
mesaId, mesaId y nombre (o número) de la mesa
salonId, salonSala a la que pertenece
comensalesCapacidad de la mesa
ticketIdTicket abierto en la mesa; null si está libre (y entonces el resto de campos del ticket también son null)
abiertoUtcCuándo se abrió el ticket
cuentaPedidatrue si ya se imprimió la cuenta
importeCentsTotal actual del ticket en céntimos
usuarioId, usuarioCamarero responsable del ticket
ultimaActividadUtcÚltima línea añadida, o la apertura si aún no hay líneas
comensalesTicketComensales declarados en el ticket

Si hubiera más de un ticket abierto en la misma mesa, se devuelve el más antiguo. Los cambios de sala se conocen en el momento por los eventos de operativa de la sección 6; esta consulta sirve para la foto inicial y para reconciliar.

5.5 GET /auditoria

Historial de eventos en orden ascendente por id. Es el mismo objeto que viaja por el flujo en tiempo real (sección 6.1).

ParámetroDescripción
desdeIdDevuelve eventos con id mayor que este (exclusivo). Es el cursor de paginación
limiteMáximo de eventos. Por defecto 500; mínimo 1 y máximo 1000 (un valor fuera de rango se ajusta, no da error)
desdeFecha y hora mínima (inclusiva), ISO 8601. Use UTC con Z, por ejemplo 2026-10-14T00:00:00Z
hastaFecha y hora máxima (exclusiva), ISO 8601
tipoSolo eventos de ese tipo (anulacion_linea, ticket_cobrado…). Un único tipo por consulta
curl -s "$BASE/auditoria?desdeId=48210&limite=2" -H "Authorization: Bearer $CLAVE"
[
  {
    "id": 48211,
    "utc": "2026-10-14T13:42:07.318Z",
    "tipo": "invitacion",
    "puntoVentaId": 1, "puntoVenta": "TPV 1",
    "dispositivoId": 3, "dispositivo": "Comandera Sala", "dispositivoTipo": "comandera",
    "usuarioId": 4, "usuario": "Marta",
    "autorizadoPorId": 1, "autorizadoPor": "Stefano",
    "ticketId": 214, "mesa": "7", "ticketNombre": null,
    "importeCents": 1150,
    "detalle": "Diavola 33 cm x1",
    "datos": null
  },
  {
    "id": 48212,
    "utc": "2026-10-14T13:44:51.904Z",
    "tipo": "ticket_cobrado",
    "puntoVentaId": 1, "puntoVenta": "TPV 1",
    "dispositivoId": 3, "dispositivo": "Comandera Sala", "dispositivoTipo": "comandera",
    "usuarioId": 4, "usuario": "Marta",
    "autorizadoPorId": null, "autorizadoPor": null,
    "ticketId": 214, "mesa": "7", "ticketNombre": null,
    "importeCents": 4850,
    "detalle": "Efectivo 48,50 (entregado 50,00, cambio 1,50)",
    "datos": {
      "formasPago": [
        { "formaPagoId": 1, "nombre": "Efectivo", "importeCents": 4850,
          "entregadoCents": 5000, "cambioCents": 150, "efectivo": true }
      ],
      "propinaCents": 0,
      "dispositivoId": 3
    }
  }
]

Paginación. Para recorrer un histórico largo, repita la consulta con desdeId igual al id del último evento recibido hasta que devuelva una lista vacía (o menos de limite elementos). Los id son crecientes y sirven de cursor. Si no pasa desdeId, la consulta empieza en el primer evento que cumpla los demás filtros.

Los valores de los ejemplos son ilustrativos; la estructura y los nombres de campo son los reales.

5.6 GET /eventos

Flujo en tiempo real (Server-Sent Events). Se describe en la sección 6.

5.7 POST /avisos

Envía un aviso al local. Se describe en la sección 7.

5.8 POST /tokens/validar

Valida un token de un solo uso emitido por el backoffice. Se describe en la sección 8.

6. Eventos en tiempo real (SSE)

6.1 El objeto evento

Es el mismo en GET /auditoria (como elemento de la lista) y en GET /eventos (como contenido de data):

CampoTipoSignificado
identeroIdentificador creciente del evento. Sirve de cursor y de clave de deduplicación
utcfecha ISO 8601Momento de la operación, en UTC, con fracción de segundo (por ejemplo 2026-10-14T13:42:07.318Z)
tipotextoTipo de evento (catálogo en 6.4)
puntoVentaId, puntoVentaentero, textoCaja a la que se imputa la operación (la del ticket). Las ventas de las comanderas se imputan a su caja de cobro
dispositivoId, dispositivo, dispositivoTipoentero, texto, textoAparato desde el que se hizo (tpv, comandera o tablet). Es el dato que hay que cruzar con una cámara o una ubicación. Puede ser null en operaciones internas o muy antiguas
usuarioId, usuarioentero, textoUsuario que hizo la operación
autorizadoPorId, autorizadoPorentero, textoEncargado que metió su PIN para autorizar la operación; null si el propio usuario tenía permiso
ticketIdenteroTicket afectado (null si no aplica)
mesatextoNúmero o nombre de la mesa del ticket (null si no tiene)
ticketNombretextoNombre del pedido para llevar o a domicilio (null si no tiene)
importeCentsenteroImporte afectado, en céntimos (null si no aplica; su significado depende del tipo)
detalletextoTexto libre para personas. No debe interpretarse automáticamente, salvo la marca (ya enviada a cocina) al final del detalle de anulacion_linea, que es estable
datosobjeto o nullDatos estructurados, solo en los tipos que los tienen (ver 6.4)

6.2 Formato del flujo

curl -N "$BASE/eventos" \
  -H "Authorization: Bearer $CLAVE" \
  -H "Last-Event-ID: 48210"

La respuesta es text/event-stream. Cada mensaje tiene tres líneas y termina con una línea en blanco:

: conectado

id: 48211
event: invitacion
data: {"id":48211,"utc":"2026-10-14T13:42:07.318Z","tipo":"invitacion","puntoVentaId":1,"puntoVenta":"TPV 1","dispositivoId":3,"dispositivo":"Comandera Sala","dispositivoTipo":"comandera","usuarioId":4,"usuario":"Marta","autorizadoPorId":1,"autorizadoPor":"Stefano","ticketId":214,"mesa":"7","ticketNombre":null,"importeCents":1150,"detalle":"Diavola 33 cm x1","datos":null}

: ping

6.3 Reanudación y deduplicación

6.4 Catálogo de tipos de evento

Los tipos de la tabla son los que genera la versión 2.61.8. importeCents indica qué representa el importe del evento. Un guion (-) significa que va a null.

Operativa de sala

Estos seis tipos describen el movimiento de la sala. No aparecen en la pantalla de Auditoría del backoffice, solo en la API.

tipoCuándoimporteCentsdatos
ticket_abiertoSe abre un ticket (mesa ocupada o venta en barra). detalle: «Mesa N» o «Barra»-{mesaId, comensales} (mesaId es null en barra)
comensalesCambian los comensales declarados. detalle: el número nuevo-{antes, despues}
enviado_cocinaSe envía una comanda. detalle: «N artículos en M líneas»-{lineas, articulos, retenidas, numeroCocina}
cuenta_pedidaSe imprime la cuentatotal del ticket-
mesa_movidaEl ticket cambia de mesa. detalle: «origen → destino»-{mesaOrigenId, mesaDestinoId}
mesa_liberadaLa mesa queda libre. Solo se emite si el ticket tenía mesa-{mesaId, motivo} con motivo = cobro, anulacion, consumo_propio o union

Cobros, cajón y facturas

tipoCuándoimporteCentsdatos
ticket_cobradoSe cierra un ticket cobrado. detalle: formas de pago, entregado y cambiototal del ticket{formasPago: [{formaPagoId, nombre, importeCents, entregadoCents, cambioCents, efectivo}], propinaCents, dispositivoId}
cajon_abiertoEl cajón se abre al cobrar--
cajon_manualApertura manual del cajón desde el TPV--
devolucionSe emite una devolución de un ticket. detalle: «Devolución del ticket #serie+número»total devuelto-
reaperturaSe reabre un ticket ya cobradototal-
factura_emitidaSe emite factura completa de un ticket. detalle: número, cliente y NIFtotal-
factura_rectificadaSe rectifica una factura. detalle: número, tipo, factura rectificada y motivototal-
consumo_propioTicket cerrado como consumo propioimporte-
venta_creditoVenta a crédito a un cliente. detalle: nombre del clienteimporte-
liquidacion_creditoCobro posterior de una venta a crédito. detalle: cliente y forma de pagoimporte-

Anulaciones, descuentos y cambios en un ticket

tipoCuándoimporteCentsdatos
anulacion_lineaSe anula una línea con motivo. detalle termina en (ya enviada a cocina) si ya estaba en preparaciónimporte de la línea (más sus añadidos)-
eliminacion_lineaSe quita una línea que aún no se había enviado a cocina. detalle termina en «sin enviar a cocina»importe de la línea-
anulacion_ticketSe anula un ticket entero. detalle: el motivototal-
invitacionUna línea queda invitada (a 0 €). detalle: línea y motivoimporte invitado-
invitacion_ticketUn ticket completo queda invitadoimporte invitado-
descuento_lineaDescuento manual en una línea. detalle: descripción de la líneaimporte descontado-
descuento_ticketDescuento manual en todo el ticket. detalle: porcentaje, por ejemplo «10%»--
cambio_precioPrecio de una línea cambiado a mano. detalle: «descripción: precio anterior → nuevo»nuevo precio unitario-
cambio_cantidad_enviadaSe cambia la cantidad de una línea ya enviada a cocina. detalle: «descripción: antes → después»precio unitario por la diferencia (negativo si se reduce)-
cambio_camareroSe asigna el ticket a otro camarero. detalle: «id anterior → id nuevo (nombre)»--
traspaso_lineasLíneas movidas a otro ticket. detalle: número de líneas y ticket destinoimporte de las líneas-
urgente_cocinaUna comanda (o líneas concretas) se marca como urgente--

Caja y jornada

tipoCuándoimporteCentsdatos
loginUn usuario entra por PIN. detalle: «Acceso por PIN»--
apertura_cajaApertura de turno con fondo declaradofondo declarado-
descuadre_cajaCierre de caja con descuadre de efectivo. detalle: el motivo indicadodescuadre (puede ser negativo)-
fichaje_manualApunte manual en el registro de jornada. detalle: trabajador, tipo, fecha y motivo--
fichaje_anuladoSe anula un apunte del registro de jornada--

Notas sobre estos tipos:

Administración

Estos tipos se generan por operaciones de administración del backoffice. Llevan siempre puntoVentaId 1 y no tienen ticket. Su detalle es un resumen para personas.

tipoCuándo
precios_masivosCambio masivo de precios
edicion_masiva_productosEdición masiva de productos
borrado_masivo_productosBaja masiva de productos
asistente_iaCambios en el catálogo aplicados por el asistente de IA
asistente_ia_deshacerCambios del asistente de IA deshechos
reset_operacionesBorrado de los datos de operaciones (formación)
nube_vincularSe generó un código para conectar otro aparato
Tipos nuevos. Pueden añadirse tipos de evento en versiones futuras. Su consumidor debe ignorar sin error los tipos que no conozca.

7. Avisos al local (POST /avisos)

Permite a un sistema externo avisar al personal del local, donde ya mira: pantallas del TPV y comanderas, Telegram y correo. Es el único endpoint que escribe, y solo envía mensajes: no modifica datos de ventas.

curl -s -X POST "$BASE/avisos" \
  -H "Authorization: Bearer $CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "nivel": "alerta",
    "titulo": "Persona en zona de caja fuera de horario",
    "texto": "Cámara Caja 1, 02:14. Sin operación en el TPV.",
    "url": "http://192.168.0.15:8090/casos/318",
    "puntoVentaId": 1,
    "soloEncargados": true,
    "canales": ["tpv", "telegram", "correo"]
  }'
CampoObligatorioDescripción
tituloSíTítulo del aviso. Sin título: 400 «El aviso necesita un título.»
nivelNoaviso (por defecto) o alerta. Otro valor: 400 «nivel debe ser «aviso» o «alerta».»
textoNoDetalle del aviso
urlNoEnlace «Ver» para abrir más información. Debe empezar por http:// o https://; si no: 400 «url debe empezar por http:// o https://.»
puntoVentaIdNoCaja o aparato al que se refiere; se muestra su nombre junto al título
soloEncargadosNoPor defecto true
canalesNoLista con tpv, telegram y/o correo. Por defecto, solo tpv. Otro valor: 400 «canales admite tpv, telegram y correo.»

Respuesta (200): los canales por los que el aviso ha salido de verdad.

{ "enviado": ["tpv"] }

Un canal no configurado se ignora sin error y simplemente no aparece en enviado. Compruebe siempre esa lista.

Dónde se ve cada canal

Privacidad del aviso en el TPV. El aviso del canal tpv viaja por el canal de eventos interno de la red del local, que lo reciben todos los equipos conectados; soloEncargados solo decide qué pantallas lo enseñan, no cifra nada. No ponga en titulo ni en texto nada que no deba ver un camarero: el detalle sensible debe ir detrás de la url.

Límite: 60 avisos por hora (ventana deslizante, para todo el local). Pasado el límite se responde 400 con «Demasiados avisos: como mucho 60 por hora.» El límite protege al local de un fallo del sistema externo que inunde las pantallas. Agrupe avisos repetidos en el lado del integrador.

8. Tokens de un solo uso (acceso desde el backoffice)

Para qué sirve. Si su sistema tiene una interfaz web propia (un visor de cámaras, un cuadro de mandos), el local puede abrirla dentro del backoffice sin una segunda contraseña. El flujo es:

  1. En Configuración › Integraciones › Sistemas externos el local guarda la dirección de su servicio (por ejemplo http://192.168.0.15:8090; debe empezar por http:// o https://).
  2. Aparece la pestaña «Cámaras» del backoffice (requiere el módulo «Cámaras con IA» contratado), que abre <dirección>/?token=<token> en un marco integrado (o en una pestaña nueva, a elección del usuario).
  3. El token lo emite AvantPOS para el usuario que está dentro del backoffice: es de un solo uso y caduca a los 60 segundos. Solo puede emitirse para usuarios con acceso al backoffice.
  4. Su servicio, al recibir la petición con ?token=, lo valida contra AvantPOS (con la clave de integración) y obtiene quién es el usuario y qué permisos tiene. Si todo es correcto, abre su sesión sin pedir credenciales.

Es una función opcional. Si su sistema no tiene interfaz propia, puede ignorarla.

curl -s -X POST "$BASE/tokens/validar" \
  -H "Authorization: Bearer $CLAVE" \
  -H "Content-Type: application/json" \
  -d '{"token":"9c1e7a4f0b2d58e3a6f14c07b9d2e85a31c6f7d04b8e92a1"}'

Respuesta 200:

{
  "usuarioId": 1,
  "nombre": "Stefano",
  "rol": "Admin",
  "permisos": {
    "puedeAnular": true, "puedeDescuentos": true, "puedeInvitar": true, "puedeReabrir": true,
    "puedeVerInformes": true, "puedeAbrirCajon": true, "puedeBackoffice": true,
    "puedeCerrarCaja": true, "puedeAutorizar": true
  }
}

Respuesta 401 (sin cuerpo) si el token no existe, ya se usó, caducó o su usuario ya no está activo. Notas:

9. Límites y buenas prácticas

Límites

Buenas prácticas

Errores frecuentes

SíntomaCausa probable
401 clave_integracion en todoClave mal copiada, clave rotada o retirada, o la integración nunca se activó
No se conecta (tiempo de espera agotado)IP o puerto incorrectos, firewall, o el equipo no está en la misma red
403 remoto_no_permitidoIntenta entrar por el acceso remoto; solo desde la red del local
401 nube_accesoEs una instancia en la nube; la API de integración no se usa con ellas
EventSource no recibe nadaEventSource no envía cabeceras ni recibe eventos con nombre en onmessage; use fetch o un cliente HTTP
El flujo se corta cada cierto tiempoUn proxy intermedio corta las conexiones inactivas o hace de búfer; el servidor envía : ping cada 25 s
Eventos duplicados tras reconectarEs normal si reconecta con un id anterior; deduplique por id
dispositivoId es nullOperación interna o anterior a la versión 2.58.3
400 al enviar un avisoFalta el título, nivel o canales no válidos, url sin http(s):// o se superó el límite horario
enviado no incluye telegram o correoEse canal no está configurado en el local

10. Protección de datos

La API devuelve nombres de empleados y lo que hace cada uno (accesos, anulaciones, descuentos, cobros, fichajes…) junto con la hora y el aparato. Son datos personales de los trabajadores del local.

Este apartado es solo orientativo y no constituye asesoramiento jurídico; cada integrador y cada local deben revisar sus obligaciones con su asesor.

11. Versionado y compromiso de estabilidad

12. Ejemplos completos

Ambos ejemplos hacen lo mismo: leen la clave del entorno, cargan los dispositivos, abren el flujo con reanudación desde el último id guardado, procesan cada evento una sola vez, y reconectan con espera exponencial. El cursor se guarda en un fichero.

12.1 Python (requests, sin librerías de SSE)

import json
import os
import time

import requests  # pip install requests

BASE = os.environ.get("AVANTPOS_BASE", "http://192.168.0.15:8085/api/integracion")
CLAVE = os.environ["AVANTPOS_CLAVE"]  # avp_...
CABECERAS = {"Authorization": f"Bearer {CLAVE}"}
FICHERO_CURSOR = "cursor.txt"


def leer_cursor():
    try:
        with open(FICHERO_CURSOR) as f:
            return int(f.read().strip())
    except (FileNotFoundError, ValueError):
        return None


def guardar_cursor(evento_id):
    with open(FICHERO_CURSOR, "w") as f:
        f.write(str(evento_id))


def eventos_sse(respuesta):
    """Genera (id, tipo, datos) por cada mensaje del flujo SSE."""
    eid, tipo, datos = None, None, []
    for linea in respuesta.iter_lines(decode_unicode=True):
        if linea is None:
            continue
        if linea == "":                      # fin de mensaje
            if datos:
                yield eid, tipo, json.loads("\n".join(datos))
            eid, tipo, datos = None, None, []
        elif linea.startswith(":"):          # comentario: ": conectado", ": ping"
            continue
        elif linea.startswith("id:"):
            eid = int(linea[3:].strip())
        elif linea.startswith("event:"):
            tipo = linea[6:].strip()
        elif linea.startswith("data:"):
            datos.append(linea[5:].lstrip())


def procesar(evento):
    # Ignore los tipos que no conozca.
    if evento["tipo"] in ("anulacion_linea", "anulacion_ticket", "invitacion", "cajon_manual"):
        print(evento["utc"], evento["tipo"], evento["usuario"], evento["dispositivo"], evento["detalle"])


def main():
    estado = requests.get(f"{BASE}/estado", headers=CABECERAS, timeout=10).json()
    print("Local:", estado["nombreComercial"], "zona:", estado["zonaHoraria"])
    dispositivos = {d["id"]: d for d in requests.get(f"{BASE}/dispositivos", headers=CABECERAS, timeout=10).json()}

    espera = 1
    while True:
        cursor = leer_cursor()
        cab = dict(CABECERAS)
        if cursor is not None:
            cab["Last-Event-ID"] = str(cursor)
        try:
            # timeout de lectura 60 s: el servidor envía un ping cada 25 s
            with requests.get(f"{BASE}/eventos", headers=cab, stream=True, timeout=(10, 60)) as r:
                r.raise_for_status()
                r.encoding = "utf-8"                  # el flujo es UTF-8 y requests supondría ISO-8859-1
                espera = 1
                for eid, tipo, evento in eventos_sse(r):
                    if cursor is not None and eid <= cursor:
                        continue                      # ya procesado
                    procesar(evento)
                    guardar_cursor(eid)               # solo después de procesar
                    cursor = eid
        except requests.RequestException as e:
            print("Flujo cortado:", e)
        time.sleep(espera)
        espera = min(espera * 2, 30)                  # espera exponencial, máximo 30 s


if __name__ == "__main__":
    main()

Enviar un aviso desde Python:

r = requests.post(f"{BASE}/avisos", headers=CABECERAS, timeout=10, json={
    "nivel": "alerta",
    "titulo": "Persona en zona de caja fuera de horario",
    "texto": "Cámara Caja 1, 02:14. Sin operación en el TPV.",
    "url": "http://192.168.0.15:8090/casos/318",
    "puntoVentaId": 1,
    "soloEncargados": True,
    "canales": ["tpv", "telegram"],
})
print(r.status_code, r.json())   # 200 {'enviado': ['tpv', 'telegram']}

12.2 JavaScript (Node.js 18 o superior, fetch)

import { readFileSync, writeFileSync } from "node:fs";

const BASE = process.env.AVANTPOS_BASE ?? "http://192.168.0.15:8085/api/integracion";
const CLAVE = process.env.AVANTPOS_CLAVE; // avp_...
const FICHERO_CURSOR = "cursor.txt";

const leerCursor = () => {
  try { return parseInt(readFileSync(FICHERO_CURSOR, "utf8"), 10) || null; }
  catch { return null; }
};
const guardarCursor = (id) => writeFileSync(FICHERO_CURSOR, String(id));

function procesar(evento) {
  // Ignore los tipos que no conozca.
  if (["anulacion_linea", "anulacion_ticket", "invitacion", "cajon_manual"].includes(evento.tipo)) {
    console.log(evento.utc, evento.tipo, evento.usuario, evento.dispositivo, evento.detalle);
  }
}

// Lee el flujo SSE y llama a alMensaje({id, tipo, datos}) por cada mensaje.
async function leerFlujo(respuesta, alMensaje) {
  const decodificador = new TextDecoder();
  let pendiente = "";
  for await (const trozo of respuesta.body) {
    pendiente += decodificador.decode(trozo, { stream: true });
    let corte;
    while ((corte = pendiente.indexOf("\n\n")) !== -1) {
      const bloque = pendiente.slice(0, corte);
      pendiente = pendiente.slice(corte + 2);
      let id = null, tipo = null;
      const datos = [];
      for (const linea of bloque.split("\n")) {
        if (linea.startsWith(":")) continue;                    // comentario / ping
        if (linea.startsWith("id:")) id = parseInt(linea.slice(3).trim(), 10);
        else if (linea.startsWith("event:")) tipo = linea.slice(6).trim();
        else if (linea.startsWith("data:")) datos.push(linea.slice(5).trimStart());
      }
      if (datos.length) await alMensaje({ id, tipo, datos: JSON.parse(datos.join("\n")) });
    }
  }
}

async function main() {
  const cab = { Authorization: `Bearer ${CLAVE}` };
  const estado = await (await fetch(`${BASE}/estado`, { headers: cab })).json();
  console.log("Local:", estado.nombreComercial, "zona:", estado.zonaHoraria);

  let espera = 1000;
  for (;;) {
    let cursor = leerCursor();
    const headers = { ...cab, ...(cursor != null ? { "Last-Event-ID": String(cursor) } : {}) };
    try {
      const r = await fetch(`${BASE}/eventos`, { headers });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      espera = 1000;
      await leerFlujo(r, async ({ id, datos }) => {
        if (cursor != null && id <= cursor) return;             // ya procesado
        procesar(datos);
        guardarCursor(id);                                      // solo después de procesar
        cursor = id;
      });
    } catch (e) {
      console.error("Flujo cortado:", e.message);
    }
    await new Promise((res) => setTimeout(res, espera));
    espera = Math.min(espera * 2, 30000);                       // espera exponencial, máximo 30 s
  }
}

main();

Validar un token en Node (en el servidor de su servicio):

const r = await fetch(`${BASE}/tokens/validar`, {
  method: "POST",
  headers: { Authorization: `Bearer ${CLAVE}`, "Content-Type": "application/json" },
  body: JSON.stringify({ token }),
});
if (r.status === 401) { /* token inválido, caducado o ya usado */ }
else { const usuario = await r.json(); /* {usuarioId, nombre, rol, permisos} */ }
Avant IT · soporte@avant-it.net · 93 694 05 02