Documentación para Desarrolladores
Integra extracción de datos con inteligencia artificial en tu sistema usando la API de Lexy.
Visión general
Una introducción a cómo funciona la integración con Lexy.
Lexy extrae datos estructurados de documentos (imágenes y PDFs) usando modelos de IA. Tu organización configura sus propios tipos de documento, que definen qué campos se extraen y cómo.
La integración externa funciona así:
- Tu sistema envía un documento vía API REST.
- Lexy lo analiza de forma asíncrona en segundo plano.
- Cuando el análisis termina, Lexy llama tu webhook URL con los resultados en JSON.
Flujo de integración
Diagrama completo de la comunicación entre tu sistema y Lexy.
/api/ingest
{ consulta_id }
tu-webhook-url
Puntos clave
202 llega en milisegundos, antes de que termine el análisis.2xx para que el envío se marque como exitoso.Quick Start
Integra Lexy en tu sistema en 5 pasos — menos de 5 minutos.
Crea una aplicación en Lexy
En el panel de administración de Lexy, ve a Integraciones → Nueva aplicación. Configura:
- Nombre — identificador de tu sistema (ej. "Mi CRM")
- Webhook URL — la URL de tu sistema que recibirá los resultados
- Tipos de documento permitidos — los tipos que tu app puede enviar
Al crear la aplicación, recibirás una sola vez: api_key y shared_secret.
Obtén el ID del tipo de documento
Desde la sección Tipos de documento del panel, copia el id del tipo que quieres analizar. Es un UUID con formato: 550e8400-e29b-41d4-a716-446655440000.
Envía tu primer documento
curl -X POST https://app.holalexy.com/api/ingest \
-H "Authorization: Bearer TU_API_KEY" \
-F "[email protected]" \
-F "document_type_id=550e8400-e29b-41d4-a716-446655440000"
Respuesta inmediata:
{
"consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Recibe el webhook con los resultados
Lexy enviará un POST a tu webhook URL con los datos extraídos:
{
"event": "consulta.completed",
"consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"organization_id": "org-uuid",
"document_type_id": "550e8400-e29b-41d4-a716-446655440000",
"analysis": {
"numero_factura": "F-2024-001",
"fecha": "2024-01-15",
"total": 150000
},
"timestamp": "2024-01-15T10:30:00.000Z"
}
Verifica la firma Recomendado
Usa el shared_secret para verificar que el webhook proviene de Lexy:
const crypto = require('crypto');
function verificarFirma(body, signature, sharedSecret) {
const expected = crypto
.createHmac('sha256', sharedSecret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Autenticación
Lexy API usa API Keys tipo Bearer para autenticar solicitudes.
Credenciales
Cada aplicación tiene dos credenciales independientes — ambas son cadenas hexadecimales de 64 caracteres (256 bits de entropía):
| Credencial | Uso | Dónde se usa |
|---|---|---|
| api_key | Autenticar requests hacia Lexy | Header Authorization |
| shared_secret | Verificar la firma de webhooks entrantes | En tu servidor |
Cómo obtener las credenciales
- En el panel de Lexy, ve a Integraciones.
- Crea una nueva aplicación o selecciona una existente.
- Al crear una aplicación, recibirás
api_keyyshared_secretuna sola vez en pantalla. - Si necesitas nuevas credenciales, usa Regenerar llaves — esto invalida las anteriores de inmediato.
api_key en texto plano y no puede recuperarla.Uso en requests
Incluye la API Key como token Bearer en el header Authorization:
Authorization: Bearer TU_API_KEY
Ejemplo con cURL:
curl -X POST https://app.holalexy.com/api/ingest \
-H "Authorization: Bearer a3f8b2c1d4e5f6789..." \
-F "[email protected]" \
-F "document_type_id=550e8400-e29b-41d4-a716-446655440000"
Seguridad
- Las API Keys se almacenan como hashes SHA-256 en la base de datos. Nunca en texto plano.
- La validación usa comparación en tiempo constante (
timingSafeEqual) para prevenir ataques de timing. - Si sospechas que tu API Key fue comprometida, regenera las llaves inmediatamente desde el panel.
- Tu aplicación tiene su propia API Key independiente.
Errores de autenticación
| Status | Descripción |
|---|---|
| 401 Unauthorized | Falta el header Authorization, o la API Key es inválida o pertenece a una app desactivada |
| 403 Forbidden | La API Key es válida, pero la app no tiene permiso para el document_type_id enviado |
Endpoint de ingestión
El único punto de entrada público. Recibe un documento, lo encola y retorna un ID de seguimiento.
Request
| Atributo | Valor |
|---|---|
| Método | POST |
| URL | https://app.holalexy.com/api/ingest |
| Content-Type | multipart/form-data |
| Autenticación | Authorization: Bearer <api_key> |
Headers requeridos
Authorization: Bearer TU_API_KEY
Content-Type: multipart/form-data
Body (multipart/form-data)
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
| file | File | Requerido | El documento a analizar |
| document_type_id | string (UUID) | Requerido | ID del tipo de documento a usar para el análisis |
Formatos de archivo aceptados
| Formato | MIME type |
|---|---|
| JPEG | image/jpeg, image/jpg |
| PNG | image/png |
application/pdf |
Response & procesamiento asíncrono
La respuesta llega inmediatamente, antes de que termine el análisis:
{
"consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Tras recibir el 202, Lexy ejecuta en segundo plano:
- Subida del archivo a almacenamiento seguro
- Análisis con IA — extrae los campos definidos en el tipo de documento
- Débito de 1 crédito de tu saldo de organización (atómico — no se debita si el análisis falla)
- Envío del webhook con los resultados a tu URL configurada
failed y el crédito no se descuenta.Ejemplos de código
curl -X POST https://app.holalexy.com/api/ingest \
-H "Authorization: Bearer a3f8b2c1d4e5f6789abcdef..." \
-F "file=@/ruta/a/factura.pdf" \
-F "document_type_id=550e8400-e29b-41d4-a716-446655440000"
const fs = require('fs');
const FormData = require('form-data'); // npm install form-data
async function enviarDocumento(rutaArchivo, documentTypeId) {
const form = new FormData();
form.append('file', fs.createReadStream(rutaArchivo));
form.append('document_type_id', documentTypeId);
const response = await fetch('https://app.holalexy.com/api/ingest', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LEXY_API_KEY}`,
...form.getHeaders(),
},
body: form,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Error ${response.status}: ${error.error}`);
}
const { consulta_id } = await response.json();
console.log('Consulta creada:', consulta_id);
return consulta_id;
}
import os
import requests
def enviar_documento(ruta_archivo: str, document_type_id: str) -> str:
api_key = os.environ['LEXY_API_KEY']
with open(ruta_archivo, 'rb') as f:
response = requests.post(
'https://app.holalexy.com/api/ingest',
headers={'Authorization': f'Bearer {api_key}'},
files={'file': f},
data={'document_type_id': document_type_id},
)
response.raise_for_status()
consulta_id = response.json()['consulta_id']
print(f'Consulta creada: {consulta_id}')
return consulta_id
<?php
function enviarDocumento(string $rutaArchivo, string $documentTypeId): string {
$apiKey = $_ENV['LEXY_API_KEY'];
$curl = curl_init('https://app.holalexy.com/api/ingest');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $apiKey"],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($rutaArchivo),
'document_type_id' => $documentTypeId,
],
]);
$body = curl_exec($curl);
$statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($statusCode !== 202) {
$error = json_decode($body, true);
throw new Exception("Error $statusCode: " . $error['error']);
}
return json_decode($body, true)['consulta_id'];
}
Errores posibles
| Status | Error | Causa |
|---|---|---|
| 400 | "Campo \"file\" requerido" | No se envió el archivo |
| 400 | "Campo \"document_type_id\" requerido" | Falta el ID del tipo de documento |
| 400 | "Tipo de archivo no permitido. Use JPG, PNG o PDF" | Formato no soportado |
| 400 | "Archivo demasiado grande. Máximo 20MB" | El archivo supera 20 MB |
| 400 | "Formato de cuerpo inválido. Se requiere multipart/form-data" | El body no es multipart/form-data |
| 401 | "Se requiere Authorization: Bearer <api_key>" | Header faltante o malformado |
| 401 | "API key inválida" | API Key incorrecta o app desactivada |
| 402 | "Créditos insuficientes" | La organización no tiene créditos disponibles |
| 403 | "Aplicación sin permiso para este tipo de documento" | La app no tiene permiso para el document_type_id |
| 404 | "Tipo de documento no encontrado o inactivo" | El document_type_id no existe o está desactivado |
Webhooks
Cómo Lexy te notifica cuando termina el análisis de un documento.
Cómo funciona
- Lexy hace un
POSTHTTP a tuwebhook_urlcon el resultado del análisis. - Tu servidor debe responder con un status
2xx(200–299) dentro de 15 segundos. - Si no responde a tiempo, o responde con un status diferente, el envío queda como
failed. - Los envíos fallidos se pueden reintentar manualmente desde el panel de Integraciones de Lexy.
Request que recibirás
Headers
Content-Type: application/json
x-lexy-signature: <hmac-sha256-hex>
Body JSON
{
"event": "consulta.completed",
"consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"document_type_id": "550e8400-e29b-41d4-a716-446655440000",
"analysis": {
"numero_factura": "F-2024-001",
"fecha_emision": "2024-01-15",
"proveedor": "Empresas XYZ S.A.S.",
"nit_proveedor": "900.123.456-7",
"subtotal": 126050,
"iva": 23950,
"total": 150000
},
"timestamp": "2024-01-15T10:30:00.000Z"
}
| Campo | Tipo | Descripción |
|---|---|---|
| event | string | Siempre "consulta.completed" |
| consulta_id | string (UUID) | ID de la consulta, corresponde al retornado por /api/ingest |
| organization_id | string (UUID) | ID de tu organización en Lexy |
| document_type_id | string (UUID) | ID del tipo de documento analizado |
| analysis | object | Resultado del análisis. La estructura varía según los campos del tipo de documento |
| timestamp | string (ISO 8601) | Momento en que se completó el análisis |
analysis contiene los campos que definiste en el tipo de documento. Los tipos posibles son string, number, boolean y fechas en formato ISO 8601. Los campos no encontrados se retornan como null.Verificación de la firma
Algoritmo
- Lee el body completo de la solicitud como string (bytes crudos, no parseado).
- Calcula
HMAC-SHA256(body, shared_secret)y conviértelo a hex. - Compara con el header
x-lexy-signatureusando comparación en tiempo constante.
const crypto = require('crypto');
function verificarFirmaLexy(req, sharedSecret) {
const signature = req.headers['x-lexy-signature'];
if (!signature) return false;
// IMPORTANTE: usar el body crudo (Buffer), no req.body ya parseado
const bodyRaw = req.rawBody;
const expected = crypto
.createHmac('sha256', sharedSecret)
.update(bodyRaw)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(signature, 'utf8')
);
} catch {
return false; // longitudes distintas
}
}
const express = require('express');
const crypto = require('crypto');
const app = express();
const LEXY_SHARED_SECRET = process.env.LEXY_SHARED_SECRET;
// Importante: guardar el body crudo antes de parsear JSON
app.use('/webhook/lexy', express.raw({ type: 'application/json' }));
app.post('/webhook/lexy', (req, res) => {
const signature = req.headers['x-lexy-signature'];
if (!signature) {
return res.status(401).json({ error: 'Firma ausente' });
}
const expected = crypto
.createHmac('sha256', LEXY_SHARED_SECRET)
.update(req.body) // req.body es Buffer gracias a express.raw()
.digest('hex');
const sigBuffer = Buffer.from(signature, 'utf8');
const expBuffer = Buffer.from(expected, 'utf8');
if (sigBuffer.length !== expBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return res.status(401).json({ error: 'Firma inválida' });
}
const payload = JSON.parse(req.body.toString());
console.log('Consulta completada:', payload.consulta_id);
procesarAnalisis(payload.consulta_id, payload.analysis);
res.status(200).json({ received: true });
});
import hashlib
import hmac
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
LEXY_SHARED_SECRET = os.environ['LEXY_SHARED_SECRET']
@app.post('/webhook/lexy')
async def recibir_webhook(request: Request):
signature = request.headers.get('x-lexy-signature')
if not signature:
raise HTTPException(status_code=401, detail='Firma ausente')
body_bytes = await request.body()
expected = hmac.new(
LEXY_SHARED_SECRET.encode('utf-8'),
body_bytes,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail='Firma inválida')
payload = await request.json()
print(f"Consulta completada: {payload['consulta_id']}")
return {'received': True}
import hashlib
import hmac
import json
import os
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
LEXY_SHARED_SECRET = os.environ['LEXY_SHARED_SECRET']
@csrf_exempt
@require_POST
def webhook_lexy(request):
signature = request.headers.get('X-Lexy-Signature')
if not signature:
return JsonResponse({'error': 'Firma ausente'}, status=401)
expected = hmac.new(
LEXY_SHARED_SECRET.encode('utf-8'),
request.body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return JsonResponse({'error': 'Firma inválida'}, status=401)
payload = json.loads(request.body)
procesar_analisis(payload['consulta_id'], payload['analysis'])
return JsonResponse({'received': True})
<?php
function verificarFirmaLexy(string $rawBody, string $signature): bool {
$sharedSecret = $_ENV['LEXY_SHARED_SECRET'];
$expected = hash_hmac('sha256', $rawBody, $sharedSecret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_LEXY_SIGNATURE'] ?? '';
if (!verificarFirmaLexy($rawBody, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Firma inválida']);
exit;
}
$payload = json_decode($rawBody, true);
procesarAnalisis($payload['consulta_id'], $payload['analysis']);
http_response_code(200);
echo json_encode(['received' => true]);
Idempotencia
Tu endpoint debe ser idempotente: si recibe el mismo consulta_id dos veces (por un reintento manual), debe procesarlo sin duplicar efectos.
const consultasProcesadas = new Set(); // en producción usa Redis o DB
app.post('/webhook/lexy', (req, res) => {
// ... verificar firma primero ...
const { consulta_id, analysis } = JSON.parse(req.body.toString());
if (consultasProcesadas.has(consulta_id)) {
return res.status(200).json({ received: true, note: 'already processed' });
}
consultasProcesadas.add(consulta_id);
procesarAnalisis(consulta_id, analysis);
res.status(200).json({ received: true });
});
Timeout y patrón de respuesta rápida
Lexy espera hasta 15 segundos. Si tu procesamiento puede tardar más, responde con 200 OK inmediatamente y procesa en background:
app.post('/webhook/lexy', async (req, res) => {
// 1. Verificar firma
// ...
// 2. Responder inmediatamente (dentro de los 15s)
res.status(200).json({ received: true });
// 3. Procesar en background (no bloquea la respuesta)
setImmediate(async () => {
const payload = JSON.parse(req.body.toString());
await procesarAnalisisEnDB(payload.consulta_id, payload.analysis);
});
});
Estados de entrega
| Estado | Descripción |
|---|---|
| pending | El webhook está en cola o enviándose |
| success | Tu servidor respondió con HTTP 2xx |
| failed | Timeout, error de red, o tu servidor respondió con un status diferente a 2xx |
Testing local con ngrok
Para probar webhooks en desarrollo, expón tu servidor local con ngrok:
ngrok http 3000
Usa la URL HTTPS generada (ej. https://abc123.ngrok-free.app/webhook/lexy) como Webhook URL en tu aplicación de Lexy.
Códigos de error
Todos los errores retornan JSON con el campo error describiendo el problema.
{
"error": "Descripción del error"
}
400 Bad Request — Request malformado
| error | Causa | Solución |
|---|---|---|
"Campo \"file\" requerido" | No se envió el archivo | Agrega el campo file en el form-data |
"Campo \"document_type_id\" requerido" | Falta el ID del tipo de documento | Agrega document_type_id |
"Tipo de archivo no permitido. Use JPG, PNG o PDF" | MIME type no aceptado | Envía JPEG, PNG o PDF |
"Archivo demasiado grande. Máximo 20MB" | El archivo supera el límite | Comprime o divide el archivo |
"Formato de cuerpo inválido. Se requiere multipart/form-data" | Body no es multipart | Usa Content-Type: multipart/form-data |
401 Unauthorized — Problema de autenticación
| error | Causa | Solución |
|---|---|---|
"Se requiere Authorization: Bearer <api_key>" | Header ausente o malformado | Formato: Authorization: Bearer TU_API_KEY |
"API key inválida" | API Key incorrecta o app desactivada | Verifica la key y que la app esté activa |
Otros errores
| Status | error | Solución |
|---|---|---|
| 402 | "Créditos insuficientes" | Adquiere más créditos desde el panel de Lexy |
| 403 | "Aplicación sin permiso para este tipo de documento" | Agrega el tipo en la config de la app, o activa "Permitir todos los tipos" |
| 404 | "Tipo de documento no encontrado o inactivo" | Verifica el ID en panel → Tipos de documento |
Errores de webhook (en tu servidor)
| Situación | Status sugerido | Descripción |
|---|---|---|
| Firma inválida | 401 | El header x-lexy-signature no coincide |
| Consulta ya procesada | 200 | Idempotencia — no es un error, ignóralo |
| Error interno tuyo | 500 | Lexy marcará el webhook como failed |
| Timeout (>15 seg) | — | Lexy cancela la espera y marca como failed |
Ejemplos completos
Integración end-to-end: envío de documentos y recepción de webhooks.
const express = require('express');
const crypto = require('crypto');
const FormData = require('form-data');
const fs = require('fs');
const app = express();
const LEXY_API_KEY = process.env.LEXY_API_KEY;
const LEXY_SHARED_SECRET = process.env.LEXY_SHARED_SECRET;
const LEXY_BASE_URL = process.env.LEXY_BASE_URL;
const DOCUMENT_TYPE_ID = process.env.LEXY_DOCUMENT_TYPE_ID;
// Map para idempotencia (en producción usa Redis o base de datos)
const procesadas = new Set();
// ─── Enviar documentos ────────────────────────────────────────
async function enviarDocumento(rutaArchivo) {
const form = new FormData();
form.append('file', fs.createReadStream(rutaArchivo));
form.append('document_type_id', DOCUMENT_TYPE_ID);
const response = await fetch(`${LEXY_BASE_URL}/api/ingest`, {
method: 'POST',
headers: {
Authorization: `Bearer ${LEXY_API_KEY}`,
...form.getHeaders(),
},
body: form,
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`Lexy API error ${response.status}: ${error}`);
}
const { consulta_id } = await response.json();
console.log(`[Lexy] Documento enviado. consulta_id: ${consulta_id}`);
return consulta_id;
}
// ─── Webhook endpoint ─────────────────────────────────────────
app.post('/webhook/lexy', express.raw({ type: 'application/json' }), (req, res) => {
// 1. Verificar firma
const signature = req.headers['x-lexy-signature'];
if (!signature) return res.status(401).json({ error: 'Firma ausente' });
const expected = crypto
.createHmac('sha256', LEXY_SHARED_SECRET)
.update(req.body)
.digest('hex');
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: 'Firma inválida' });
}
// 2. Parsear payload
const payload = JSON.parse(req.body.toString());
const { consulta_id, analysis } = payload;
// 3. Idempotencia
if (procesadas.has(consulta_id)) {
console.log(`[Lexy] Webhook duplicado ignorado: ${consulta_id}`);
return res.status(200).json({ received: true });
}
procesadas.add(consulta_id);
// 4. Responder rápido, procesar en background
res.status(200).json({ received: true });
setImmediate(() => {
console.log(`[Lexy] Procesando consulta: ${consulta_id}`);
console.log('[Lexy] Análisis:', JSON.stringify(analysis, null, 2));
// Aquí guardarías en tu base de datos
});
});
app.listen(3000, () => console.log('Servidor en http://localhost:3000'));
import hashlib
import hmac
import os
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import httpx
LEXY_API_KEY = os.environ["LEXY_API_KEY"]
LEXY_SHARED_SECRET = os.environ["LEXY_SHARED_SECRET"]
LEXY_BASE_URL = os.environ["LEXY_BASE_URL"]
DOCUMENT_TYPE_ID = os.environ["LEXY_DOCUMENT_TYPE_ID"]
app = FastAPI()
# Set de idempotencia (en producción usa Redis o base de datos)
consultas_procesadas: set[str] = set()
# ─── Enviar documentos ────────────────────────────────────────
async def enviar_documento(ruta_archivo: str) -> str:
async with httpx.AsyncClient() as client:
with open(ruta_archivo, "rb") as f:
response = await client.post(
f"{LEXY_BASE_URL}/api/ingest",
headers={"Authorization": f"Bearer {LEXY_API_KEY}"},
files={"file": f},
data={"document_type_id": DOCUMENT_TYPE_ID},
timeout=30.0,
)
if response.status_code != 202:
error = response.json().get("error", "Error desconocido")
raise Exception(f"Error {response.status_code}: {error}")
return response.json()["consulta_id"]
# ─── Webhook ─────────────────────────────────────────────────
@app.post("/webhook/lexy")
async def recibir_webhook(request: Request):
# 1. Verificar firma
signature = request.headers.get("x-lexy-signature")
if not signature:
raise HTTPException(status_code=401, detail="Firma ausente")
body_bytes = await request.body()
expected = hmac.new(
LEXY_SHARED_SECRET.encode("utf-8"),
body_bytes,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Firma inválida")
# 2. Parsear payload
payload = await request.json()
consulta_id = payload["consulta_id"]
analysis = payload["analysis"]
# 3. Idempotencia
if consulta_id in consultas_procesadas:
return JSONResponse({"received": True})
consultas_procesadas.add(consulta_id)
# 4. Procesar
print(f"[Lexy] Consulta completada: {consulta_id}")
print(f"[Lexy] Análisis: {analysis}")
return JSONResponse({"received": True})
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class LexyWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
// 1. Verificar firma
$signature = $request->header('X-Lexy-Signature');
if (!$signature) {
return response()->json(['error' => 'Firma ausente'], 401);
}
$rawBody = $request->getContent();
$expected = hash_hmac('sha256', $rawBody,
config('services.lexy.shared_secret'));
if (!hash_equals($expected, $signature)) {
return response()->json(['error' => 'Firma inválida'], 401);
}
// 2. Parsear payload
$payload = $request->json()->all();
$consultaId = $payload['consulta_id'];
$analysis = $payload['analysis'];
// 3. Idempotencia (cache de Laravel)
$cacheKey = "lexy_webhook_{$consultaId}";
if (cache()->has($cacheKey)) {
return response()->json(['received' => true]);
}
cache()->put($cacheKey, true, now()->addDays(7));
// 4. Procesar en background
ProcessLexyAnalysis::dispatch($consultaId, $analysis);
return response()->json(['received' => true]);
}
}
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class LexyIngestService
{
private string $apiKey;
private string $baseUrl;
private string $documentTypeId;
public function __construct()
{
$this->apiKey = config('services.lexy.api_key');
$this->baseUrl = config('services.lexy.base_url');
$this->documentTypeId = config('services.lexy.document_type_id');
}
public function enviarDocumento(string $rutaArchivo): string
{
$response = Http::withToken($this->apiKey)
->timeout(30)
->attach('file', file_get_contents($rutaArchivo), basename($rutaArchivo))
->post("{$this->baseUrl}/api/ingest", [
'document_type_id' => $this->documentTypeId,
]);
if ($response->status() !== 202) {
$error = $response->json('error', 'Error desconocido');
throw new \RuntimeException("Error {$response->status()}: {$error}");
}
return $response->json('consulta_id');
}
}
Variables de entorno
LEXY_API_KEY=tu_api_key_de_64_chars_hex
LEXY_SHARED_SECRET=tu_shared_secret_de_64_chars_hex
LEXY_BASE_URL=https://app.holalexy.com
LEXY_DOCUMENT_TYPE_ID=550e8400-e29b-41d4-a716-446655440000
Testing con cURL
curl -X POST "$LEXY_BASE_URL/api/ingest" \
-H "Authorization: Bearer $LEXY_API_KEY" \
-F "file=@./factura.pdf" \
-F "document_type_id=$LEXY_DOCUMENT_TYPE_ID" \
-v
BODY='{"event":"consulta.completed","consulta_id":"test-123","organization_id":"org-456","document_type_id":"doc-789","analysis":{"nombre":"Juan Perez","total":150000},"timestamp":"2024-01-15T10:30:00.000Z"}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$LEXY_SHARED_SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhook/lexy \
-H "Content-Type: application/json" \
-H "x-lexy-signature: $SIGNATURE" \
-d "$BODY"
Checklist de producción
Antes de ir a producción, verifica:
-
La
api_keyy elshared_secretestán en variables de entorno (nunca en el código) -
Tu endpoint de webhook verifica la firma
x-lexy-signatureantes de procesar -
Tu endpoint responde con
200 OKen menos de 15 segundos -
Implementaste idempotencia (evitar procesar el mismo
consulta_iddos veces) - Tu webhook URL usa HTTPS (no HTTP)
-
Tu webhook URL es accesible desde internet (no es
localhost) -
Probaste con un documento real y verificaste que el
analysistiene los campos esperados - Tienes logs de los webhooks entrantes para debugging
Lexy API Docs · v1
Volver al inicio