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.
}
}