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:
| Pieza | Qué hace | Dónde corre |
|---|---|---|
| Agente | Binario Windows que traduce JSON a ESC/POS y lo manda a la impresora (USB o red) | PC del cliente |
| Panel SaaS | Emite api_token, valida licencias, publica actualizaciones | Tu servidor |
POST /api/v1/print con tu api_token, igual que hablas con cualquier otro endpoint del panel.Empezar rápido
- Regístrate contra el panel SaaS y guarda el
api_tokenque te devuelve (ver Registro). - Instala el agente en la PC con la impresora conectada, y edita su
config.jsoncon eseapi_tokeny el alias de cada impresora (ver abajo). - Desde tu app, manda el ticket a
http://localhost:9005/print(si estás en la misma red que la caja) o aPOST /api/v1/printen el panel (si es un pedido online, servidor remoto) — mismo formato JSON en ambos casos.
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:
- 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.
- Mandarlo — por HTTP directo al agente si tu sistema corre en la misma red que la impresora, o a nuestra
POST /api/v1/printsi es un servidor remoto (pedidos online). Nosotros nos encargamos de hacerlo llegar al agente por dentro. - 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
| Escenario | Necesitas |
|---|---|
| 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. |
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.
| Campo | Tipo | Obligatorio | Detalle |
|---|---|---|---|
auth_token | string | Sí | Debe coincidir exactamente con el api_token del config.json de ese agente. Si no coincide: 401. |
printer_id | string | Sí | Debe ser una clave que exista en printers dentro del config.json de ese agente (ej. "caja_1"). Si no existe: 422. |
options | object | No | Hoy solo se lee open_drawer (boolean). Si se omite, no pasa nada raro — simplemente no se abre el cajón. |
commands | array de objetos | Sí (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
| type | Campos | Qué hace |
|---|---|---|
init | — | Reinicia la impresora y selecciona la página de código configurada |
align | value: left / center / right | Alineación de lo que sigue |
text | value, bold, size/width/height, font | Línea de texto (se ajusta sola en varias líneas si no cabe) — control de tamaño detallado en la sección de abajo |
divider | style: carácter a repetir (ej. "-", "=") | Línea separadora a todo el ancho |
text_columns | left, right | 2 columnas (ej. producto + precio) |
table_row | cols: [{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 |
feed | lines | Avanza papel N líneas |
qr | value, size: small / medium / large | Código QR nativo (lo dibuja el firmware de la impresora) |
image | data: PNG/JPEG en base64, max_width_dots (opcional) | Logo/gráfico — ver sección dedicada |
codepage | value: 858 / 1252 / 850 / 437 | Cambia la tabla de caracteres a mitad de ticket |
cut | — | Corte de papel |
beep | times (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 }
| Campo | Detalle |
|---|---|
width | Multiplicador de ancho, 1-8 (default 1). Valores fuera de rango se acotan automáticamente. |
height | Multiplicador 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. |
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 }
| Campo | Detalle |
|---|---|
data | La 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_dots | Opcional, 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. |
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.
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.
/printImprime 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: ..." }
/previewMismo 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
/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": ""
}
/printersImpresoras 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.
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./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." }
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:
| Lenguaje | Con qué (sin instalar nada nuevo, casi siempre) |
|---|---|
| Ruby | Net::HTTP o gem 'httparty' |
| Go | net/http (librería estándar) |
| Delphi / Pascal | TNetHTTPClient — común en sistemas contables/POS antiguos en Perú |
| Visual Basic .NET / VB6 con COM | System.Net.Http.HttpClient, o WinHttp.WinHttpRequest en VB6 clásico |
| Cualquier otro | Busca "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étodo | Uso |
|---|---|
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.
/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": "..." }
/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" }
/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
/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
/api/v1/licenses/validateEsto 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
/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.
/api/v1/agent/installerDistinto 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íntoma | Causa |
|---|---|
| Tabla desalineada / texto pegado entre columnas | Ancho 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 tildes | Impresora 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 PowerShell | Windows 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 minutos | Corregido 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 nada | type 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 visible | commands 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 irreconocibles | Imagen 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 |