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: normal / double | Línea de texto (se ajusta sola en varias líneas si no cabe) |
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 |
options.open_drawer: true abre el cajón de dinero junto con el ticket.
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"
}
/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 entregó el evento; el agente lo procesa en cuanto le llegue
{ "status": "queued", "message": "evento enviado; el agente lo procesará en cuanto lo reciba por su conexión a Soketi" }
// 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" }
200 confirma que el panel entregó el evento a Soketi — no que la impresora ya terminó de imprimir (eso pasa unos milisegundos después, del lado del agente, de forma asíncrona). Si necesitas saber el resultado real, tu propio sistema debe llevar su propio registro (por ahora el agente no reporta de vuelta al panel qué imprimió — ver "Problemas comunes" para más contexto).¿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' => 'queued', '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}) | Línea de texto |
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
/api/v1/developers/register// Request
{ "company_name": "Flash Motors", "email": "contacto@flashmotors.pe" }
// Response — guarda este api_token, es tu credencial de gestión
// y la del primer terminal
{ "developer_id": 1, "api_token": "tok_live_...", "message": "..." }
/api/v1/developers/me (header 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 |