Skip to Content
v1.5Documentación oficial de la API LX CloudPos · estable en producción

Java

Cliente completo sobre el HttpClient del JDK (Java 17 o superior), sin dependencias de red externas: solo Jackson para serializar JSON. Incluye emisión, consulta, descarga de PDF y XML, reenvío de correo, anulación, próximo consecutivo y un receptor de webhooks con verificación HMAC.

Setup

<!-- pom.xml --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency>

Cliente completo

LxsClient mantiene un único HttpClient con timeout de 30 s y agrega los headers de autenticación en cada llamada. Cada emisión genera un Idempotency-Key propio, salvo que se proporcione uno para reintentar la misma operación.

// LxsClient.java package cr.lxsyscr.integracion; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.UUID; public class LxsClient { private static final String BASE_URL = "https://api.pos.lxsyscr.com"; private final HttpClient http; private final ObjectMapper mapper; private final String apiKey; private final String baseUrl; public LxsClient(String apiKey) { this(apiKey, BASE_URL); } public LxsClient(String apiKey, String baseUrl) { this.apiKey = apiKey; this.baseUrl = baseUrl; this.http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); this.mapper = new ObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL); } // ── Emisión ────────────────────────────────────────────────────────── public FacturaResponse emitir(FacturaRequest data) throws Exception { return emitir(data, UUID.randomUUID().toString()); } /** El idempotencyKey debe generarse UNA vez y repetirse en cada reintento. */ public FacturaResponse emitir(FacturaRequest data, String idempotencyKey) throws Exception { HttpRequest req = base("/api/v1/facturas") .header("Content-Type", "application/json") .header("Idempotency-Key", idempotencyKey) .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(data))) .build(); HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString()); exigirExito(res); return mapper.readValue(res.body(), FacturaResponse.class); } // ── Consulta y descargas ───────────────────────────────────────────── public FacturaResponse consultar(String clave) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/facturas/" + clave).GET().build(), HttpResponse.BodyHandlers.ofString()); exigirExito(res); return mapper.readValue(res.body(), FacturaResponse.class); } public byte[] descargarPdf(String clave) throws Exception { HttpResponse<byte[]> res = http.send( base("/api/v1/facturas/" + clave + "/pdf").GET().build(), HttpResponse.BodyHandlers.ofByteArray()); if (res.statusCode() >= 400) { throw new LxsException(res.statusCode(), new String(res.body())); } return res.body(); } public String descargarXml(String clave) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/facturas/" + clave + "/xml").GET().build(), HttpResponse.BodyHandlers.ofString()); exigirExito(res); return res.body(); } /** Acuse firmado por Hacienda. Devuelve null mientras responde 202. */ public String descargarRespuestaHacienda(String clave) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/facturas/" + clave + "/respuesta").GET().build(), HttpResponse.BodyHandlers.ofString()); if (res.statusCode() == 202) { return null; } exigirExito(res); return res.body(); } // ── Otras operaciones ──────────────────────────────────────────────── public void reenviarEmail(String clave) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/facturas/" + clave + "/email") .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()); exigirExito(res); } public void anular(String clave) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/facturas/" + clave).DELETE().build(), HttpResponse.BodyHandlers.ofString()); exigirExito(res); } public long proximoConsecutivo(String tipo) throws Exception { HttpResponse<String> res = http.send( base("/api/v1/consecutivos/proximo?tipo=" + tipo).GET().build(), HttpResponse.BodyHandlers.ofString()); exigirExito(res); return mapper.readTree(res.body()).get("proximo").asLong(); } // ── Internos ───────────────────────────────────────────────────────── private HttpRequest.Builder base(String path) { return HttpRequest.newBuilder() .uri(URI.create(baseUrl + path)) .header("X-Api-Key", apiKey) .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)); } private void exigirExito(HttpResponse<String> res) { if (res.statusCode() >= 400) { throw new LxsException(res.statusCode(), res.body()); } } // ── Tipos ──────────────────────────────────────────────────────────── public record FacturaRequest( String tipo, int idUsuario, Cliente cliente, String condicionVenta, Integer plazoCredito, String medioPago, String moneda, Double tipoCambio, String codigoActividad, String comentario, List<Linea> lineas, List<Referencia> referencias) {} public record Cliente(Integer idCliente, String identificacion, String tipoIdentificacion, String nombre, String correo, String telefono) { public static Cliente porId(int idCliente) { return new Cliente(idCliente, null, null, null, null, null); } } public record Linea(Integer idProducto, String codigoProducto, double cantidad, Double precioUnitario, Double montoDescuento, String naturalezaDescuento) { public static Linea de(int idProducto, double cantidad) { return new Linea(idProducto, null, cantidad, null, null, null); } } public record Referencia(String tipoDocumento, String numero, String fechaEmision, String codigo, String razon) {} public record FacturaResponse( String clave, String numeroConsecutivo, String tipoDocumento, String estadoHacienda, double totalComprobante, String moneda, String fechaEmision) {} public static class LxsException extends RuntimeException { public final int status; public LxsException(int status, String body) { super("HTTP " + status + ": " + body); this.status = status; } } }

Uso

LxsClient lxs = new LxsClient(System.getenv("LXS_API_KEY")); var factura = lxs.emitir(new LxsClient.FacturaRequest( "FacturaElectronica", 7, // idUsuario: obligatorio LxsClient.Cliente.porId(42), "01", null, "01", "CRC", null, null, null, List.of(LxsClient.Linea.de(100, 2)), List.of())); System.out.println("Clave: " + factura.clave());

Reintentos con idempotencia

La clave se genera fuera del bucle: así los reintentos reutilizan la misma y no se emite un comprobante por intento.

public FacturaResponse emitirConReintentos(LxsClient lxs, FacturaRequest data) throws Exception { String idempotencyKey = UUID.randomUUID().toString(); // una sola vez for (int intento = 0; intento < 3; intento++) { try { return lxs.emitir(data, idempotencyKey); } catch (LxsClient.LxsException e) { boolean recuperable = e.status >= 500 || e.status == 429; if (!recuperable || intento == 2) { throw e; } Thread.sleep(2000L * (intento + 1)); } } throw new IllegalStateException("Reintentos agotados"); }

Receptor de webhook (Spring Boot)

El controller recibe el body crudo como byte[]: si se deja que Spring lo deserialice a un objeto y luego se vuelve a serializar, cambian los espacios y el orden de las claves, y el HMAC deja de coincidir. La comparación usa MessageDigest.isEqual, que es de tiempo constante.

// LxsWebhookController.java package cr.lxsyscr.integracion; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; @RestController @RequestMapping("/webhooks/lxs") public class LxsWebhookController { private static final String SECRET = System.getenv("LXS_WEBHOOK_SECRET"); private final ObjectMapper mapper = new ObjectMapper(); @PostMapping public ResponseEntity<Void> handle( @RequestBody byte[] body, @RequestHeader(value = "X-Lxs-Signature", required = false) String signature) throws Exception { if (signature == null || !firmaValida(body, signature)) { return ResponseEntity.status(401).build(); } JsonNode evento = mapper.readTree(body); String nonce = evento.get("nonce").asText(); String clave = evento.get("clave").asText(); String estado = evento.get("ind_estado").asText(); // Responder rápido: Hacienda no espera y el emisor reintenta a los 10 s. // El procesamiento pesado va a una cola, no dentro del request. procesarAsync(nonce, clave, estado); return ResponseEntity.ok().build(); } private boolean firmaValida(byte[] body, String header) throws Exception { String recibida = header.startsWith("sha256=") ? header.substring(7) : header; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String esperada = HexFormat.of().formatHex(mac.doFinal(body)); return MessageDigest.isEqual( recibida.getBytes(StandardCharsets.UTF_8), esperada.getBytes(StandardCharsets.UTF_8)); } private void procesarAsync(String nonce, String clave, String estado) { // Deduplicar por nonce antes de actuar: un mismo evento puede llegar // más de una vez si el reintento se cruza con la respuesta. } }