API v1

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.
💡
No se necesita polling. El webhook es el mecanismo principal para recibir los resultados. Tu sistema no necesita consultar el estado — Lexy te notifica automáticamente.

Flujo de integración

Diagrama completo de la comunicación entre tu sistema y Lexy.

🖥️
Tu sistema
Tu aplicación
Lexy API
app.holalexy.com
POST /api/ingest
Authorization: Bearer <api_key> · multipart
202 { consulta_id }
En milisegundos · antes de completar análisis
2–20 segundos
POST tu-webhook-url
x-lexy-signature · { event, consulta_id, analysis }
200 OK confirma recepción

Puntos clave

La respuesta 202 llega en milisegundos, antes de que termine el análisis.
El análisis puede tardar entre 2 y 20 segundos dependiendo del documento.
Tu webhook debe responder con HTTP 2xx para que el envío se marque como exitoso.
Si el webhook falla, puedes reintentarlo manualmente desde el panel de Integraciones.

Quick Start

Integra Lexy en tu sistema en 5 pasos — menos de 5 minutos.

1

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.

⚠️
Guárdalos de forma segura. No se vuelven a mostrar. Guarda ambas credenciales en un gestor de secretos (variables de entorno, AWS Secrets Manager, HashiCorp Vault, etc.).
2

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.

3

Envía tu primer documento

bash
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:

json 202 Accepted
{
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
4

Recibe el webhook con los resultados

Lexy enviará un POST a tu webhook URL con los datos extraídos:

json
{
  "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"
}
5

Verifica la firma Recomendado

Usa el shared_secret para verificar que el webhook proviene de Lexy:

javascript
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):

CredencialUsoDónde se usa
api_keyAutenticar requests hacia LexyHeader Authorization
shared_secretVerificar la firma de webhooks entrantesEn tu servidor

Cómo obtener las credenciales

  1. En el panel de Lexy, ve a Integraciones.
  2. Crea una nueva aplicación o selecciona una existente.
  3. Al crear una aplicación, recibirás api_key y shared_secret una sola vez en pantalla.
  4. Si necesitas nuevas credenciales, usa Regenerar llaves — esto invalida las anteriores de inmediato.
⚠️
Importante: Guarda ambas credenciales en un gestor de secretos (AWS Secrets Manager, HashiCorp Vault, variables de entorno, etc.). Lexy no almacena la api_key en texto plano y no puede recuperarla.

Uso en requests

Incluye la API Key como token Bearer en el header Authorization:

http
Authorization: Bearer TU_API_KEY

Ejemplo con cURL:

bash
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

StatusDescripción
401 UnauthorizedFalta el header Authorization, o la API Key es inválida o pertenece a una app desactivada
403 ForbiddenLa 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.

POST /api/ingest 202 Accepted

Request

AtributoValor
MétodoPOST
URLhttps://app.holalexy.com/api/ingest
Content-Typemultipart/form-data
AutenticaciónAuthorization: Bearer <api_key>

Headers requeridos

http
Authorization: Bearer TU_API_KEY
Content-Type: multipart/form-data

Body (multipart/form-data)

CampoTipoRequeridoDescripció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

FormatoMIME type
JPEGimage/jpeg, image/jpg
PNGimage/png
PDFapplication/pdf
ℹ️
Tamaño máximo: 20 MB. Para PDFs de múltiples páginas, se analiza el documento completo.

Response & procesamiento asíncrono

La respuesta llega inmediatamente, antes de que termine el análisis:

json 202 Accepted
{
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Tras recibir el 202, Lexy ejecuta en segundo plano:

  1. Subida del archivo a almacenamiento seguro
  2. Análisis con IA — extrae los campos definidos en el tipo de documento
  3. Débito de 1 crédito de tu saldo de organización (atómico — no se debita si el análisis falla)
  4. Envío del webhook con los resultados a tu URL configurada
Si cualquier paso falla, la consulta queda con estado failed y el crédito no se descuenta.

Ejemplos de código

bash
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"
javascript
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;
}
python
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
<?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

StatusErrorCausa
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

  1. Lexy hace un POST HTTP a tu webhook_url con el resultado del análisis.
  2. Tu servidor debe responder con un status 2xx (200–299) dentro de 15 segundos.
  3. Si no responde a tiempo, o responde con un status diferente, el envío queda como failed.
  4. Los envíos fallidos se pueden reintentar manualmente desde el panel de Integraciones de Lexy.

Request que recibirás

Headers

http
Content-Type: application/json
x-lexy-signature: <hmac-sha256-hex>

Body JSON

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"
}
CampoTipoDescripción
eventstringSiempre "consulta.completed"
consulta_idstring (UUID)ID de la consulta, corresponde al retornado por /api/ingest
organization_idstring (UUID)ID de tu organización en Lexy
document_type_idstring (UUID)ID del tipo de documento analizado
analysisobjectResultado del análisis. La estructura varía según los campos del tipo de documento
timestampstring (ISO 8601)Momento en que se completó el análisis
ℹ️
El objeto 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

⚠️
Siempre verifica la firma antes de procesar el payload. Esto garantiza que la solicitud proviene de Lexy y no de un tercero malicioso.

Algoritmo

  1. Lee el body completo de la solicitud como string (bytes crudos, no parseado).
  2. Calcula HMAC-SHA256(body, shared_secret) y conviértelo a hex.
  3. Compara con el header x-lexy-signature usando comparación en tiempo constante.
javascript
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
  }
}
javascript
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 });
});
python
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}
python
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
<?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]);
⚠️
Advertencia: Siempre usa el body crudo (sin parsear) para calcular la firma. Si primero parseas el JSON y luego lo re-serializas, la cadena puede cambiar y la firma no coincidirá.

Idempotencia

Tu endpoint debe ser idempotente: si recibe el mismo consulta_id dos veces (por un reintento manual), debe procesarlo sin duplicar efectos.

javascript
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:

javascript
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

EstadoDescripción
pendingEl webhook está en cola o enviándose
successTu servidor respondió con HTTP 2xx
failedTimeout, 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:

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

json4xx Error
{
  "error": "Descripción del error"
}

400 Bad Request — Request malformado

errorCausaSolución
"Campo \"file\" requerido"No se envió el archivoAgrega el campo file en el form-data
"Campo \"document_type_id\" requerido"Falta el ID del tipo de documentoAgrega document_type_id
"Tipo de archivo no permitido. Use JPG, PNG o PDF"MIME type no aceptadoEnvía JPEG, PNG o PDF
"Archivo demasiado grande. Máximo 20MB"El archivo supera el límiteComprime o divide el archivo
"Formato de cuerpo inválido. Se requiere multipart/form-data"Body no es multipartUsa Content-Type: multipart/form-data

401 Unauthorized — Problema de autenticación

errorCausaSolución
"Se requiere Authorization: Bearer <api_key>"Header ausente o malformadoFormato: Authorization: Bearer TU_API_KEY
"API key inválida"API Key incorrecta o app desactivadaVerifica la key y que la app esté activa

Otros errores

StatuserrorSolució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ónStatus sugeridoDescripción
Firma inválida401El header x-lexy-signature no coincide
Consulta ya procesada200Idempotencia — no es un error, ignóralo
Error interno tuyo500Lexy 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.

lexy-integration.js
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'));
main.py
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})
LexyWebhookController.php
<?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]);
    }
}
LexyIngestService.php
<?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

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

bash — Enviar un documento
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
bash — Simular un webhook
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_key y el shared_secret están en variables de entorno (nunca en el código)
  • Tu endpoint de webhook verifica la firma x-lexy-signature antes de procesar
  • Tu endpoint responde con 200 OK en menos de 15 segundos
  • Implementaste idempotencia (evitar procesar el mismo consulta_id dos 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 analysis tiene los campos esperados
  • Tienes logs de los webhooks entrantes para debugging

Lexy API Docs · v1

Volver al inicio