PrintAgent

Impresión térmica ESC/POS desde el navegador (LAN) o desde la nube (pedidos online), sin abrir puertos en la red del cliente.

El sistema tiene tres piezas:

PiezaQué haceDónde corre
AgenteBinario Windows que traduce JSON a ESC/POS y lo manda a la impresora (USB o red)PC del cliente
Panel SaaSEmite api_token, valida licencias, publica actualizacionesTu servidor
Puertas adentro, la impresión remota viaja por Soketi (un canal WebSocket privado entre el panel y el agente). Es un detalle de implementación nuestro: tu sistema nunca instala un SDK de Soketi/Pusher ni conoce sus credenciales — solo le habla a POST /api/v1/print con tu api_token, igual que hablas con cualquier otro endpoint del panel.

Empezar rápido

  1. Regístrate contra el panel SaaS y guarda el api_token que te devuelve (ver Registro).
  2. Instala el agente en la PC con la impresora conectada, y edita su config.json con ese api_token y el alias de cada impresora (ver abajo).
  3. Desde tu app, manda el ticket a http://localhost:9005/print (si estás en la misma red que la caja) o a POST /api/v1/print en el panel (si es un pedido online, servidor remoto) — mismo formato JSON en ambos casos.
¿Primera vez armando un ticket? Abre sdk/sandbox.html con el agente corriendo: arma el JSON, dale a Vista previa y revisa el resultado antes de imprimir en papel real.

¿Es automático? — No. Esto es lo que tiene que hacer tu sistema

El agente no imprime nada por su cuenta. No sabe qué es una venta, un pedido ni un comprobante — es un traductor: recibe un JSON con instrucciones (texto, QR, logo, corte) y las convierte a los bytes que entiende la impresora térmica. Tu sistema (el que emite la boleta/factura, en PHP o lo que uses) es responsable de:

  1. Armar el JSON del ticket con los datos de esa venta puntual: items, totales, el texto del QR (normalmente el link de consulta SUNAT), el logo si aplica.
  2. Mandarlo — por HTTP directo al agente si tu sistema corre en la misma red que la impresora, o a nuestra POST /api/v1/print si es un servidor remoto (pedidos online). Nosotros nos encargamos de hacerlo llegar al agente por dentro.
  3. Revisar la respuesta (200 = imprimió, 401/422 = algo falló) y decidir qué hacer si falla — reintentar, avisar al cajero, etc. El agente no reintenta solo.

Requisitos según el escenario

EscenarioNecesitas
Tu sistema PHP corre en la misma red que la caja (ej. un POS local, un intranet) Nada especial — cualquier PHP con curl habilitado (viene por defecto). Llamas directo a http://IP-DE-LA-CAJA:9005/print.
Tu sistema corre en un servidor remoto (pedidos online, delivery, multi-sucursal) Nada especial tampoco — llamas a POST https://printagent.efa-api.com/api/v1/print con tu api_token (header X-Api-Token) y el mismo curl/requests/fetch de siempre. No hay SDK de terceros que instalar: nosotros hacemos llegar el ticket al agente por dentro, sin que tú sepas ni necesites la IP de la caja.
El agente no valida que el JSON tenga sentido de negocio (no revisa que el total cuadre, ni que el RUC exista) — eso es responsabilidad de tu sistema. El agente solo valida que el JSON tenga la forma correcta (ver estructura abajo) y que el auth_token/printer_id sean válidos.

Estructura del JSON — qué es obligatorio

El agente valida la forma del ticket con esta estructura exacta (definida en internal/printer/dispatcher.go). Cualquier campo fuera de esto se ignora; si falta uno obligatorio, la petición falla.

CampoTipoObligatorioDetalle
auth_tokenstringSíDebe coincidir exactamente con el api_token del config.json de ese agente. Si no coincide: 401.
printer_idstringSíDebe ser una clave que exista en printers dentro del config.json de ese agente (ej. "caja_1"). Si no existe: 422.
optionsobjectNoHoy solo se lee open_drawer (boolean). Si se omite, no pasa nada raro — simplemente no se abre el cajón.
commandsarray de objetosSí (conceptualmente)Lista ordenada de instrucciones — ver tabla completa. Si se manda vacío o se omite, el agente responde 200 igual pero no imprime nada visible (no es un error, pero tampoco tiene sentido de negocio).
{
  "auth_token": "tok_live_...",   // obligatorio, exacto
  "printer_id": "caja_1",         // obligatorio, debe existir en config.json
  "options": { "open_drawer": false },  // opcional
  "commands": [ ... ]             // obligatorio en la práctica
}

Cada elemento de commands es un objeto con un campo type obligatorio (string) — el resto de campos depende de cuál type sea, según la tabla de abajo. Un type desconocido simplemente se ignora (no rompe el ticket, pero tampoco imprime nada — útil saberlo si algo "desaparece" del ticket: revisa que el type esté bien escrito).

Comandos disponibles

typeCamposQué hace
init—Reinicia la impresora y selecciona la página de código configurada
alignvalue: left / center / rightAlineación de lo que sigue
textvalue, bold, size/width/height, fontLínea de texto (se ajusta sola en varias líneas si no cabe) — control de tamaño detallado en la sección de abajo
dividerstyle: carácter a repetir (ej. "-", "=")Línea separadora a todo el ancho
text_columnsleft, right2 columnas (ej. producto + precio)
table_rowcols: [{text, width, align}]Fila de N columnas de ancho fijo; si una celda no cabe, pasa a la siguiente línea sin desalinear las demás
feedlinesAvanza papel N líneas
qrvalue, size: small / medium / largeCódigo QR nativo (lo dibuja el firmware de la impresora)
imagedata: PNG/JPEG en base64, max_width_dots (opcional)Logo/gráfico — ver sección dedicada
codepagevalue: 858 / 1252 / 850 / 437Cambia la tabla de caracteres a mitad de ticket
cut—Corte de papel
beeptimes (1-9, default 3), duration (1-9, ~100ms cada unidad, default 1)Hace sonar el buzzer de la impresora (si lo tiene) — ver sección dedicada

options.open_drawer: true abre el cajón de dinero junto con el ticket.

Pitido de la impresora (buzzer)

Muchas impresoras térmicas ESC/POS genéricas (XPrinter, Zjiang, y las "POS-80C" de los ejemplos de esta página) traen un buzzer integrado para alertar cuando sale un ticket — pensado sobre todo para la comanda de cocina, donde nadie mira la pantalla. No es parte del estándar ESC/POS de Epson (sus impresoras reales no tienen parlante); si tu impresora no lo soporta, el comando se ignora sin más, no rompe el resto del ticket.

Hay dos formas de activarlo, independientes entre sí:

Opción A — comando en el ticket (control caso por caso)

{ "type": "beep", "times": 3, "duration": 1 }

Agrégalo donde quieras en commands — normalmente al principio, para que alerte antes de que salga el papel.

Opción B — beep_on_print en config.json (automático, sin tocar tu sistema)

"printers": {
  "cocina": {
    "name": "COCINA", "type": "usb_spooler", "max_chars": 48, "codepage": "858",
    "beep_on_print": true,
    "beep_times": 3,
    "beep_duration_units": 1
  }
}

Con esto, cada trabajo enviado a esa impresora pita solo, sin que tu ERP/sistema de facturación tenga que mandar el comando beep — útil para prender el pitido en una impresora puntual (ej. solo Cocina, no Caja) tocando únicamente la configuración del agente en esa PC. beep_times/beep_duration_units son opcionales (default 3/1 si se omiten). Si un ticket trae además el comando beep explícito, suenan los dos — no se anulan entre sí.

Tamaño de letra por línea

Cada línea de text se puede escalar en ancho y alto de forma independiente, de 1x a 8x cada eje — no solo "normal" o "doble". Útil para, por ejemplo, que el nombre del producto salga bien grande en la comanda de cocina, pero el nombre del mozo un poco más chico, o la razón social más grande que el RUC, todo en el mismo ticket.

{ "type": "text", "value": "LOMO SALTADO", "width": 3, "height": 2 }
{ "type": "text", "value": "Mozo: Juan Pérez", "width": 1, "height": 1 }
{ "type": "text", "value": "FLASH MOTORS S.A.C.", "width": 2, "height": 3, "bold": true }
CampoDetalle
widthMultiplicador de ancho, 1-8 (default 1). Valores fuera de rango se acotan automáticamente.
heightMultiplicador de alto, 1-8 (default 1) — totalmente independiente de width.
size: "double"Atajo antiguo, sigue funcionando por compatibilidad: equivale a width: 2, height: 2. Si el comando trae width/height explícitos, esos ganan sobre size.
Este multiplicador solo escala hacia arriba — no existe un "0.5x" para achicar por debajo del tamaño base de la fuente activa (así funciona el comando ESC/POS real de la impresora, GS ! n, no es una limitación nuestra). Para texto genuinamente más chico que lo normal (ej. un RUC discreto al pie del ticket), usa "font": "b" — selecciona la fuente condensada que casi toda impresora térmica trae de fábrica, en vez de intentar "reducir" la fuente normal.
{ "type": "text", "value": "RUC: 20612397016", "font": "b" }

Logos e imágenes

El comando image imprime un logo (como el de tu boleta de ejemplo) usando el comando nativo de gráficos de la impresora (GS v 0). El agente decodifica, escala si hace falta, y aplica difuminado (dithering) — no manda una imagen en color/escala de grises tal cual, porque la impresora térmica solo entiende negro/blanco por punto.

{ "type": "image", "data": "iVBORw0KGgoAAAANSUhEUgAA...", "max_width_dots": 384 }
CampoDetalle
dataLa imagen completa (PNG o JPEG) codificada en base64 — no una URL ni una ruta de archivo. El agente no lee nada del disco ni de internet por seguridad; tu backend arma el base64 y lo manda dentro del JSON.
max_width_dotsOpcional, default 384 (~48mm a 203dpi, cabe en 58mm y 80mm). Si el logo es más ancho, se reduce proporcionalmente — nunca se recorta ni se deforma.
Un logo con muchos detalles finos o texto pequeño puede salir borroso — las impresoras térmicas son de 180-203 DPI, no HD. Un logo simple de alto contraste (como un ícono en negro sobre blanco) siempre sale mejor que una foto o un degradado complejo.

Cómo generar el base64 en PHP

$logoBase64 = base64_encode(file_get_contents('logo.png'));

$commands = [
    ['type' => 'image', 'data' => $logoBase64],
    ['type' => 'feed', 'lines' => 1],
    ['type' => 'text', 'value' => 'FLASH MOTORS', 'bold' => true],
    // ... resto del ticket
];

Tip: no leas el logo del disco en cada venta — cárgalo una vez al iniciar tu app y guárdalo en memoria/caché, ya que el base64 no cambia entre tickets.

Tildes, ñ/Ñ y páginas de código

Las impresoras térmicas no hablan UTF-8: usan tablas de un byte por carácter. Por defecto el agente transcodifica todo el texto a CP858 (Latin I + Euro — incluye ñ/Ñ/á/é/í/ó/ú/ü), que es la tabla más compatible en impresoras vendidas en LatAm.

Muchos clones ESC/POS fabricados en China (marcas genéricas tipo "POS-80C") arrancan en modo de caracteres chinos (GBK) por defecto. Si ves glifos CJK en vez de tildes, es esto — el agente ya manda el comando FS . para desactivarlo antes de imprimir, pero si integras ESC/POS crudo por tu cuenta en otro sistema, acuérdate de este detalle.

Si tu impresora no soporta CP858 correctamente, cambia "codepage" en el config.json de esa impresora a "1252", "850" o "437" — o usa el comando {"type":"codepage","value":"..."} para probar varias en un mismo ticket.

API del agente — HTTP local

El agente expone estos endpoints en http://localhost:9005 (puerto configurable). Todos con CORS abierto para permitir llamadas desde cualquier página en la misma red.

POST/print

Imprime el ticket. Content-Type: application/json.

Request — ejemplo real (boleta con logo, tabla e items, QR)

{
  "auth_token": "tok_live_empresa_89412a",
  "printer_id": "caja_1",
  "options": { "open_drawer": false },
  "commands": [
    { "type": "init" },
    { "type": "align", "value": "center" },
    { "type": "image", "data": "iVBORw0KGgoAAAANSUhEUgAA..." },
    { "type": "text", "value": "FLASH MOTORS", "bold": true, "size": "double" },
    { "type": "text", "value": "R.U.C: 20612397016" },
    { "type": "divider", "style": "-" },
    { "type": "align", "value": "left" },
    { "type": "text", "value": "BOLETA DE VENTA ELECTRONICA B001-001313" },
    {
      "type": "table_row",
      "cols": [
        { "text": "CT.", "width": 3 },
        { "text": "DESCRIPCION", "width": 27 },
        { "text": "IMP", "width": 8, "align": "right" }
      ]
    },
    {
      "type": "table_row",
      "cols": [
        { "text": "1", "width": 3 },
        { "text": "PARRILLA PUL.180", "width": 27 },
        { "text": "180.00", "width": 8, "align": "right" }
      ]
    },
    { "type": "divider", "style": "=" },
    { "type": "align", "value": "right" },
    { "type": "text", "value": "TOTAL: S/ 180.00", "bold": true },
    { "type": "align", "value": "center" },
    { "type": "feed", "lines": 1 },
    { "type": "qr", "value": "https://facturacion.pe/comprobante/b001-1313" },
    { "type": "feed", "lines": 3 },
    { "type": "cut" }
  ]
}

Response

// 200 OK — imprimió
{ "status": "success", "message": "Trabajo impreso correctamente" }

// 401 — auth_token no coincide con el configurado en este agente
{ "error": "token de autenticación inválido" }

// 422 — printer_id no existe en el config.json de este agente,
// o falló el envío al hardware (impresora apagada, sin papel, etc.)
{ "error": "la impresora 'caja_2' no está configurada en este agente" }

// 400 — el body no es JSON válido
{ "error": "JSON inválido: ..." }
POST/preview

Mismo body y mismas reglas de validación que /print, pero no toca la impresora: devuelve una vista de texto plano y el hex de los bytes ESC/POS reales. Los mismos códigos de error (401/422/400) aplican igual.

// 200 OK
{
  "preview": "...texto plano tal como quedaría el ticket...",
  "raw_bytes_hex": "1b401c2e1b7413...",
  "max_chars": 48,
  "codepage": "858"
}

Cada línea de text en preview lleva una etiqueta con su tamaño real — así se confirma con certeza qué width/height/font va a tomar cada campo, sin tener que leer el código que armó el ticket ni decodificar raw_bytes_hex a mano:

[2x2 negrita]            FLASH MOTORS S.A.C.
[fuente chica]              RUC: 20612397016
[normal] Mozo: Juan Perez
[3x1] LOMO SALTADO
GET/status
{
  "status": "running",
  "agent_version": "0.1.0",
  "soketi_connected": true,
  "printers": [{ "id": "caja_1", "name": "POS-80C", "type": "usb_spooler", "profile": "80mm" }],
  "last_print_at": "2026-08-17T10:30:00Z",
  "last_print_ok": true,
  "last_error": ""
}
GET/printers

Impresoras que Windows detecta instaladas (independiente de lo que esté en config.json) — útil para encontrar el nombre exacto que usar como "name" de una impresora usb_spooler.

{ "detected": ["POS-80C", "Microsoft Print to PDF", "..."] }

También existe GET /dashboard: panel visual con todo lo anterior, más un botón para hacer una impresión de prueba.

Impresión desde la nube

Para pedidos online / delivery, donde nadie está frente al navegador del POS: tu backend llama a POST /api/v1/print en el panel SaaS (no al agente directamente). El panel reenvía el ticket al agente por su canal privado y este despacha la impresión apenas lo recibe — no necesitas que la tienda tenga ningún puerto abierto ni sepas su IP.

Puertas adentro esto usa Soketi (WebSocket), pero es un detalle de implementación nuestro, no tuyo: no instalas ningún SDK de Pusher/Soketi, no conoces ni necesitas app_id/app_key/app_secret. Ese secreto es de la plataforma completa (no por cliente) — si lo repartiéramos a cada integrador, cualquiera podría disparar eventos hacia el canal de otro cliente. Por eso el único que le habla a Soketi somos nosotros; tú solo hablas con nuestra API usando tu propio api_token, igual que con cualquier otro endpoint del panel.
POST/api/v1/print (header X-Api-Token)

Mismo printer_id/commands/options que /print del agente — la diferencia es que el auth_token no va en el body (ya lo identifica el header) y la URL es la de tu panel, no la del agente.

// Request — header: X-Api-Token: tok_live_empresa_89412a
{
  "printer_id": "cocina",
  "commands": [
    { "type": "text", "value": "PEDIDO #204", "bold": true, "size": "double" },
    { "type": "divider", "style": "-" },
    { "type": "text_columns", "left": "1x Ceviche Mixto", "right": "Sin picante" },
    { "type": "feed", "lines": 2 },
    { "type": "cut" }
  ]
}

// 200 OK — el panel CONFIRMÓ que el agente está conectado antes de mandarlo
{ "status": "sent", "message": "el agente está conectado y recibió el ticket" }

// 401 — falta el header X-Api-Token o no se reconoce
{ "error": "api_token no reconocido" }

// 403 — el token existe pero está revocado/suspendido
{ "error": "este api_token está suspendido o revocado" }

// 400 — falta printer_id o commands no es un array
{ "error": "printer_id y commands (array) son requeridos" }

// 503 — el agente de ese terminal NO está conectado ahora mismo (PC apagada,
// sin internet, o el agente caído). El ticket NO se envió — reintenta luego.
{ "error": "el agente de este terminal no está conectado ahora mismo (PC apagada, sin internet, o agente caído) — el ticket NO se envió. Reintenta cuando vuelva a estar en línea." }
Antes de disparar el evento, el panel confirma con Soketi que hay un agente realmente conectado a ese canal (reintentando unos segundos por si está a mitad de una reconexión breve) — así un 200 es una confirmación real de entrega, no una promesa vacía. Lo que no confirma todavía es que el papel ya salió de la impresora (eso pasa milisegundos después, del lado del agente) — si tu caso de uso necesita esa certeza (ej. comprobantes contables donde "se perdió" no es aceptable), maneja el 503 con un reintento desde tu propio sistema mientras trabajamos en una cola persistente con confirmación de impresión real.

¿No usas ninguno de estos? Cualquier lenguaje sirve

No hay ningún SDK obligatorio para el camino HTTP directo (Opción A de abajo) — es POST + JSON + un header, el contrato HTTP más simple que existe. Si tu lenguaje puede hacer una petición HTTP con body (todos pueden), puedes integrar PrintAgent sin librerías de por medio:

LenguajeCon qué (sin instalar nada nuevo, casi siempre)
RubyNet::HTTP o gem 'httparty'
Gonet/http (librería estándar)
Delphi / PascalTNetHTTPClient — común en sistemas contables/POS antiguos en Perú
Visual Basic .NET / VB6 con COMSystem.Net.Http.HttpClient, o WinHttp.WinHttpRequest en VB6 clásico
Cualquier otroBusca "HTTP POST JSON" + tu lenguaje — el 100% de los lenguajes con soporte de red modernos lo tienen de fábrica o con una librería de una línea

El único requisito real: armar el JSON con la forma correcta y mandarlo por POST con Content-Type: application/json. Todo lo demás (autenticación, impresión, QR, logo) vive dentro de ese JSON, no en la capa de transporte.

PHP

Dos caminos — elige uno según dónde corra tu sistema (ver requisitos arriba).

Opción A — HTTP directo (misma red que la caja)

<?php

function imprimirTicket(array $ticket, string $agentUrl = 'http://192.168.1.50:9005/print'): array
{
    $ch = curl_init($agentUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode($ticket, JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT => 10,
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($response === false) {
        throw new RuntimeException('No se pudo conectar con el agente (¿está prendido? ¿IP correcta?)');
    }

    $data = json_decode($response, true);
    if ($httpCode !== 200) {
        throw new RuntimeException('El agente rechazó el ticket: ' . ($data['error'] ?? "HTTP $httpCode"));
    }

    return $data; // ['status' => 'success', 'message' => '...']
}

// --- armar el ticket a partir de TU venta ---------------------------------
$venta = [
    'ruc' => '20612397016',
    'items' => [
        ['cant' => 1, 'desc' => 'PARRILLA PUL.180', 'precio' => 180.00],
    ],
    'total' => 180.00,
    'urlComprobante' => 'https://facturacion.pe/comprobante/b001-1313',
];

$commands = [
    ['type' => 'init'],
    ['type' => 'align', 'value' => 'center'],
    ['type' => 'text', 'value' => 'FLASH MOTORS', 'bold' => true, 'size' => 'double'],
    ['type' => 'text', 'value' => 'R.U.C: ' . $venta['ruc']],
    ['type' => 'divider', 'style' => '-'],
    ['type' => 'align', 'value' => 'left'],
];

foreach ($venta['items'] as $item) {
    $commands[] = [
        'type' => 'text_columns',
        'left' => "{$item['cant']}x {$item['desc']}",
        'right' => 'S/ ' . number_format($item['precio'], 2),
    ];
}

$commands[] = ['type' => 'divider', 'style' => '='];
$commands[] = ['type' => 'align', 'value' => 'right'];
$commands[] = ['type' => 'text', 'value' => 'TOTAL: S/ ' . number_format($venta['total'], 2), 'bold' => true];
$commands[] = ['type' => 'align', 'value' => 'center'];
$commands[] = ['type' => 'feed', 'lines' => 1];
$commands[] = ['type' => 'qr', 'value' => $venta['urlComprobante']];
$commands[] = ['type' => 'feed', 'lines' => 3];
$commands[] = ['type' => 'cut'];

try {
    $resultado = imprimirTicket([
        'auth_token' => 'tok_live_empresa_89412a', // el de TU terminal, del panel
        'printer_id' => 'caja_1',
        'commands' => $commands,
    ]);
    echo "Impreso: " . $resultado['message'];
} catch (RuntimeException $e) {
    // Aquí decides qué hacer: reintentar, avisar al cajero, loguearlo, etc.
    error_log('Fallo al imprimir: ' . $e->getMessage());
}

Opción B — Cloud (sistema remoto)

Ningún SDK que instalar — es el mismo curl de la Opción A, solo que apunta a nuestro panel en vez de al agente, y el api_token va en el header en vez de en el body.

<?php

function imprimirTicketRemoto(array $ticket, string $apiToken, string $panelUrl = 'https://printagent.efa-api.com/api/v1/print'): array
{
    $ch = curl_init($panelUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json', "X-Api-Token: $apiToken"],
        CURLOPT_POSTFIELDS => json_encode($ticket, JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT => 10,
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $data = json_decode($response, true);
    if ($httpCode !== 200) {
        throw new RuntimeException('El panel rechazó el ticket: ' . ($data['error'] ?? "HTTP $httpCode"));
    }
    return $data; // ['status' => 'sent', 'message' => '...']
}

// $commands es el mismo array armado arriba (Opción A) — se reutiliza igual.
// Nota: aquí NO va 'auth_token' en el body, el api_token ya identifica el
// terminal vía el header X-Api-Token.
imprimirTicketRemoto([
    'printer_id' => 'cocina',
    'commands'   => $commands,
], 'tok_live_empresa_89412a');

La única diferencia real entre A y B es a quién le llega el JSON primero — el array commands que armas es exactamente el mismo en los dos casos.

Node.js

HTTP directo

const res = await fetch('http://192.168.1.50:9005/print', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    auth_token: 'tok_live_empresa_89412a',
    printer_id: 'caja_1',
    commands: [
      { type: 'init' },
      { type: 'text', value: 'TICKET DE VENTA', bold: true },
      { type: 'cut' },
    ],
  }),
});
if (!res.ok) {
  const err = await res.json();
  throw new Error(err.error || `HTTP ${res.status}`);
}

Cloud (sistema remoto)

Mismo fetch, apuntando a nuestro panel en vez de al agente — nada de instalar un SDK de Soketi/Pusher:

const res = await fetch('https://printagent.efa-api.com/api/v1/print', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Api-Token': 'tok_live_empresa_89412a' },
  body: JSON.stringify({
    printer_id: 'cocina',
    commands: [
      { type: 'text', value: 'PEDIDO #204', bold: true, size: 'double' },
      { type: 'divider', style: '-' },
      { type: 'text_columns', left: '1x Ceviche Mixto', right: 'Sin picante' },
      { type: 'feed', lines: 2 },
      { type: 'cut' }
    ]
  })
});
if (!res.ok) {
  const err = await res.json();
  throw new Error(err.error || `HTTP ${res.status}`);
}

Python

HTTP directo (requests)

import requests

def imprimir_ticket(ticket: dict, agent_url: str = "http://192.168.1.50:9005/print") -> dict:
    resp = requests.post(agent_url, json=ticket, timeout=10)
    data = resp.json()
    if resp.status_code != 200:
        raise RuntimeError(f"El agente rechazó el ticket: {data.get('error', resp.status_code)}")
    return data

ticket = {
    "auth_token": "tok_live_empresa_89412a",
    "printer_id": "caja_1",
    "commands": [
        {"type": "init"},
        {"type": "align", "value": "center"},
        {"type": "text", "value": "TICKET DE VENTA", "bold": True},
        {"type": "divider", "style": "-"},
        {"type": "text_columns", "left": "1x Lomo Saltado", "right": "S/ 38.00"},
        {"type": "qr", "value": "https://facturacion.pe/comprobante/b001-492"},
        {"type": "feed", "lines": 3},
        {"type": "cut"},
    ],
}

try:
    resultado = imprimir_ticket(ticket)
    print("Impreso:", resultado["message"])
except (requests.RequestException, RuntimeError) as e:
    print("Fallo al imprimir:", e)

Cloud (sistema remoto)

Mismo requests, apuntando a nuestro panel — sin pip install extra:

def imprimir_ticket_remoto(ticket: dict, api_token: str, panel_url: str = "https://printagent.efa-api.com/api/v1/print") -> dict:
    resp = requests.post(panel_url, json=ticket, headers={"X-Api-Token": api_token}, timeout=10)
    data = resp.json()
    if resp.status_code != 200:
        raise RuntimeError(f"El panel rechazó el ticket: {data.get('error', resp.status_code)}")
    return data

# ticket es el mismo dict de arriba, pero SIN "auth_token" — el api_token
# ya identifica el terminal vía el header X-Api-Token.
del ticket["auth_token"]
imprimir_ticket_remoto(ticket, api_token="tok_live_empresa_89412a")

Java

HTTP directo (HttpClient, sin dependencias — Java 11+)

import java.net.URI;
import java.net.http.*;
import java.net.http.HttpResponse.BodyHandlers;

String ticketJson = """
    {
      "auth_token": "tok_live_empresa_89412a",
      "printer_id": "caja_1",
      "commands": [
        {"type": "init"},
        {"type": "text", "value": "TICKET DE VENTA", "bold": true},
        {"type": "cut"}
      ]
    }
    """;

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("http://192.168.1.50:9005/print"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(ticketJson))
    .build();

HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200) {
    throw new RuntimeException("El agente rechazó el ticket: " + response.body());
}

Si ya usas Spring Boot, cambia HttpClient por tu RestTemplate/WebClient de siempre — el contrato HTTP es el mismo, esto es solo para no forzarte a agregar una dependencia si no la necesitas.

Cloud (sistema remoto)

Mismo HttpClient, apuntando a nuestro panel — sin dependencia de Pusher/Soketi:

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://printagent.efa-api.com/api/v1/print"))
    .header("Content-Type", "application/json")
    .header("X-Api-Token", "tok_live_empresa_89412a")
    .POST(HttpRequest.BodyPublishers.ofString(ticketJsonSinAuthToken))
    .build();

HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200) {
    throw new RuntimeException("El panel rechazó el ticket: " + response.body());
}

C# / .NET

HTTP directo (HttpClient)

using System.Net.Http.Json;

var ticket = new {
    auth_token = "tok_live_empresa_89412a",
    printer_id = "caja_1",
    commands = new object[] {
        new { type = "init" },
        new { type = "text", value = "TICKET DE VENTA", bold = true },
        new { type = "cut" },
    },
};

using var client = new HttpClient();
var response = await client.PostAsJsonAsync("http://192.168.1.50:9005/print", ticket);

if (!response.IsSuccessStatusCode)
{
    var error = await response.Content.ReadAsStringAsync();
    throw new Exception($"El agente rechazó el ticket: {error}");
}

Cloud (sistema remoto)

Mismo HttpClient, apuntando a nuestro panel — sin paquete de Pusher/Soketi:

using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-Api-Token", "tok_live_empresa_89412a");

var ticketRemoto = new { printer_id = "cocina", commands = ticket.commands }; // sin auth_token
var response = await client.PostAsJsonAsync("https://printagent.efa-api.com/api/v1/print", ticketRemoto);

if (!response.IsSuccessStatusCode)
{
    var error = await response.Content.ReadAsStringAsync();
    throw new Exception($"El panel rechazó el ticket: {error}");
}

SDK JavaScript

Para integrar desde el navegador (mismo red que el agente), usa sdk/printagent-sdk.js — sin dependencias, API encadenable:

const agent = new PrintAgent({ token: 'tok_live_...', port: 9005 });

await agent
  .init()
  .align('center')
  .text('TICKET DE VENTA', { bold: true })
  .divider()
  .textColumns('1x Lomo Saltado', 'S/ 38.00')
  .qr('https://facturacion.pe/comprobante/b001-492')
  .feed(3)
  .cut()
  .send('caja_1');   // o .preview('caja_1') para depurar sin imprimir
MétodoUso
init()Reinicia + selecciona codepage
align(value)left / center / right
text(value, {bold, size, width, height, font})Línea de texto — width/height (1-8, independientes) para tamaño detallado, font: 'b' para la fuente condensada (más chica que la normal)
divider(style)Línea separadora
textColumns(left, right)2 columnas
tableRow(cols)N columnas: [{text, width, align}]
feed(lines)Avance de papel
qr(value, size)Código QR
image(base64Data, maxWidthDots)Logo/gráfico — pásale el base64 sin el prefijo data:image/png;base64,
codepage(value)Cambia tabla de caracteres
cut()Corte
openDrawer()Abre el cajón al enviar
send(printerId)Imprime y reinicia el builder
preview(printerId)Vista previa sin imprimir
status()Estado del agente local

Panel SaaS — Registro y login

El api_token y la cuenta del panel son cosas distintas: el api_token es la credencial que usa el agente (va en config.json); para entrar al panel web (/index.html) el dueño de la cuenta usa email + contraseña.

POST/api/v1/developers/register
// Request
{ "company_name": "Flash Motors", "email": "contacto@flashmotors.pe", "password": "al-menos-8-caracteres" }

// Response — guarda el api_token para el agente; session_token es opcional
// (sirve si vas a manejar el panel por API en vez de por el navegador)
{ "developer_id": 1, "api_token": "tok_live_...", "session_token": "eyJ...", "message": "..." }
POST/api/v1/developers/login
// Request
{ "email": "contacto@flashmotors.pe", "password": "..." }

// Response
{ "session_token": "eyJ...", "company_name": "Flash Motors" }

// 401 — email o contraseña incorrectos (mensaje genérico a propósito,
// no revela cuál de los dos fue)
{ "error": "email o contraseña incorrectos" }
GET/api/v1/developers/me (header X-Session-Token o X-Api-Token)
{
  "company_name": "Flash Motors", "email": "...", "plan_type": "starter",
  "max_terminals": 5,
  "tokens": [{ "api_token": "...", "terminal_label": "principal", "machine_id": null, "status": "active" }]
}

Tokens por terminal

POST/api/v1/developers/me/tokens (header X-Api-Token)

Cada PC/impresora debería tener su propio api_token (respeta el límite max_terminals del plan).

// Request (body opcional)
{ "terminal_label": "caja_2" }

// 201 Created
{ "api_token": "tok_live_..." }

// 403 — llegaste al límite de terminales de tu plan
{ "error": "límite de 5 terminales activos alcanzado para el plan 'starter'" }

También existen PATCH .../tokens/:id (renombrar), POST .../tokens/:id/status (revocar/reactivar, body {"status":"revoked"}), y DELETE .../tokens/:id (solo si ya está revocado) — se usan desde el panel web, no hace falta integrarlos a mano en tu sistema de facturación.

Validación de licencia

POST/api/v1/licenses/validate

Esto lo llama el agente automáticamente al iniciar, no se integra a mano. Un token solo puede estar activo en una máquina a la vez — la primera que valida "reclama" el machine_id. Devuelve un JWT (RS256) que el agente cachea para operar offline hasta 72h si se pierde la conexión.

// Request (lo arma el agente solo)
{ "api_token": "tok_live_...", "machine_id": "hash-sha256-del-hardware", "agent_version": "0.1.0" }

// 200 — válido
{ "valid": true, "jwt": "eyJhbGciOiJSUzI1NiIs..." }

// 403 — revocado, eliminado, o ya activado en otra máquina
{ "valid": false, "message": "api_token no reconocido o suspendido" }

Auto-actualización e instalador

GET/api/v1/agent/latest
{ "version": "0.1.1", "url": "https://.../releases/printagent-0.1.1.exe", "sha256": "...", "notes": "...", "signature": "..." }

El agente lo consulta periódicamente (update.check_interval_minutes en su config.json, default 6h). Si hay versión nueva: descarga, verifica el SHA-256 y la firma RS256, y se reemplaza a sí mismo sin intervención — ver saas-panel/releases/README.md para publicar una versión.

GET/api/v1/agent/installer

Distinto del anterior: esto es para la primera instalación en una PC nueva (el .exe de Inno Setup), no para actualizar un agente que ya está corriendo. Sin autenticación — es lo que usan los botones "Descargar PrintAgent" del registro y del panel.

// 200 OK
{ "version": "0.1.0", "url": "https://.../releases/PrintAgentSetup-0.1.0.exe", "sha256": "..." }

// 404 — todavía no se publicó ningún instalador
{ "error": "no hay ningún instalador publicado todavía" }

Problemas comunes

SíntomaCausa
Tabla desalineada / texto pegado entre columnasAncho mal calibrado — revisa max_chars del perfil de esa impresora en config.json (suele ser 32 para 58mm, 48 para 80mm, pero varía por modelo)
Salen caracteres chinos en vez de tildesImpresora clon china en modo Kanji — el agente ya lo maneja; si integras ESC/POS por tu cuenta, manda FS . (0x1C 0x2E) antes de tu texto
Tildes/ñ salen mal solo al probar con PowerShellWindows PowerShell 5.1 re-codifica strings no-ASCII en Invoke-WebRequest -Body. Envía el archivo como bytes crudos ([System.IO.File]::ReadAllBytes) o usa HttpClient/curl en vez de -Body $string
El agente se reconecta a Soketi cada ~2 minutosCorregido desde v0.1.0 — el cliente ahora manda su propio pusher:ping periódico; si lo ves en una versión vieja, actualiza el agente
El agente no arranca / "no se puede operar"Sin conexión al servidor de licencias y sin caché válida (o caché vencida >72h). Necesita internet al menos una vez cada 72h
Un comando del ticket "desaparece", no imprime nadatype mal escrito (ej. "txt" en vez de "text") — un type desconocido se ignora en silencio, no da error. Usa /preview para verlo antes de gastar papel
El ticket responde 200 pero no imprime nada visiblecommands vacío o ausente — no es un error para el agente (JSON válido, token y printer_id correctos), pero tampoco hay nada que imprimir
El logo sale como manchas negras irreconociblesImagen con demasiado detalle/degradados para 1-bit — usa un logo simple de alto contraste; si sigue mal, prueba subir max_width_dots (más resolución) o simplificar la imagen antes de convertirla
POST /api/v1/print devuelve 503 "el agente ... no está conectado"La PC de ese terminal está apagada, sin internet, o el agente no está corriendo — el panel confirma la conexión antes de enviar, así que este error significa que el ticket no se mandó (a diferencia de antes, que respondía 200 igual y el ticket se perdía en silencio). Reintenta cuando el agente vuelva a estar en línea, o revisa GET /status del agente si tienes acceso a esa red