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_tokenstringDebe coincidir exactamente con el api_token del config.json de ese agente. Si no coincide: 401.
printer_idstringDebe 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
initReinicia la impresora y selecciona la página de código configurada
alignvalue: left / center / rightAlineación de lo que sigue
textvalue, bold, size: normal / doubleLínea de texto (se ajusta sola en varias líneas si no cabe)
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
cutCorte 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 }
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"
}
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 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" }
Un 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:

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' => '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étodoUso
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

POST/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": "..." }
GET/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

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