API v1.1

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
En milisegundos · antes de completar análisis
202 { consulta_id }
2–20 segundos
Análisis con Gemini AI
x-lexy-signature · { event, consulta_id, analysis }
POST tu-webhook-url
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:

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. 1 En el panel de Lexy, ve a Integraciones.
  2. 2 Crea una nueva aplicación o selecciona una existente.
  3. 3 Al crear una aplicación, recibirás api_key y shared_secret una sola vez en pantalla.
  4. 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

Opción 1: Subir archivo directamente (multipart/form-data)

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

Body (multipart/form-data)

CAMPOTIPOREQUERIDODESCRIPCIÓN
fileFileRequeridoEl documento a analizar
document_type_idstring (UUID)RequeridoID del tipo de documento a usar para el análisis

Opción 2: Descargar desde URL (application/json)

json
{
  "file_url": "https://example.com/documents/invoice.pdf",
  "document_type_id": "550e8400-e29b-41d4-a716-446655440000"
}
CAMPOTIPOREQUERIDODESCRIPCIÓN
file_urlstring (HTTPS URL)RequeridoURL HTTPS del documento a descargar
document_type_idstring (UUID)RequeridoID del tipo de documento a usar para el análisis
ℹ️
Restricciones para URLs: Solo HTTPS · timeout 30s · hasta 20 redirects · máximo 20 MB.

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:

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

Tras recibir el 202, Lexy ejecuta en segundo plano:

  1. 1 Subida del archivo a almacenamiento seguro
  2. 2 Análisis con IA — extrae los campos definidos en el tipo de documento
  3. 3 Débito de 1 crédito de tu saldo de organización (atómico — no se debita si el análisis falla)
  4. 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

cURL — multipart
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"
cURL — URL
curl -X POST https://app.holalexy.com/api/ingest \
  -H "Authorization: Bearer a3f8b2c1d4e5f6789abcdef..." \
  -H "Content-Type: application/json" \
  -d '{
    "file_url": "https://example.com/documents/factura.pdf",
    "document_type_id": "550e8400-e29b-41d4-a716-446655440000"
  }'

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"Formato no soportado
400"Archivo demasiado grande. Máximo 20MB"El archivo supera 20 MB
400"Solo se permiten URLs HTTPS"La URL usa HTTP en lugar de HTTPS
400"Tiempo de espera agotado al descargar el archivo"Descarga tardó más de 30 segundos
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

Endpoints de estado

Consulta el estado de un análisis sin esperar el webhook.

💡
Recomendación: Usa webhooks como mecanismo principal — son más eficientes que el polling. Usa estos endpoints como alternativa o para depuración.
GET /api/consultas/{consulta_id}

Estado individual

ATRIBUTOVALOR
MétodoGET
URLhttps://app.holalexy.com/api/consultas/{consulta_id}
AutenticaciónAuthorization: Bearer <api_key>

Respuesta — análisis en curso:

200 OK — pending
{
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "pending",
  "document_type_id": "550e8400-e29b-41d4-a716-446655440000",
  "application_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "created_at": "2024-01-15T10:30:00.000Z"
}

Respuesta — análisis completado:

200 OK — completed
{
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "completed",
  "document_type_id": "550e8400-e29b-41d4-a716-446655440000",
  "application_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "created_at": "2024-01-15T10:30:00.000Z",
  "analysis": {
    "numero_factura": "F-2024-001",
    "fecha_emision": "2024-01-15",
    "total": 150000
  }
}

Respuesta — análisis fallido:

200 OK — failed
{
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "failed",
  "document_type_id": "550e8400-e29b-41d4-a716-446655440000",
  "application_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "created_at": "2024-01-15T10:30:00.000Z",
  "error": {
    "message": "Error en el modelo de reconocimiento de documentos"
  }
}
CAMPOTIPODESCRIPCIÓN
consulta_idstring (UUID)ID único de la consulta
statusstringpending | completed | failed
document_type_idstring (UUID)ID del tipo de documento analizado
application_idstring (UUID)ID de la aplicación que envió el documento
created_atstring (ISO 8601)Fecha y hora en que se recibió el documento
analysisobjectSolo en completed: resultado del análisis
error.messagestringSolo en failed: mensaje descriptivo del error
GET /api/consultas

Lista con filtros

Retorna una lista paginada de consultas de tu organización, con soporte para filtrar por estado.

Query params (todos opcionales)

PARÁMETROTIPOPOR DEFECTODESCRIPCIÓN
statusstringFiltrar: pending | completed | failed
limitinteger20Cantidad de resultados por página. Máximo 100
offsetinteger0Número de resultados a saltar (paginación)
200 OK
{
  "data": [
    {
      "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "completed",
      "document_type_id": "550e8400-e29b-41d4-a716-446655440000",
      "application_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
      "created_at": "2024-01-15T10:30:00.000Z",
      "analysis": { "numero_factura": "F-2024-001", "total": 150000 }
    }
  ],
  "limit": 20,
  "offset": 0
}

Paginación:

bash
# Página 1
GET /api/consultas?limit=20&offset=0

# Página 2
GET /api/consultas?limit=20&offset=20

Polling con JavaScript:

javascript
async function esperarResultado(consultaId, intervalMs = 3000, maxIntentos = 20) {
  for (let i = 0; i < maxIntentos; i++) {
    const res = await fetch(
      `https://app.holalexy.com/api/consultas/${consultaId}`,
      { headers: { Authorization: `Bearer ${process.env.LEXY_API_KEY}` } }
    );
    const data = await res.json();
    if (data.status === 'completed') return data.analysis;
    if (data.status === 'failed') throw new Error(data.error.message);
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error('Tiempo de espera agotado');
}

Webhooks

Cómo Lexy te notifica cuando termina el análisis de un documento.

Cómo funciona

  1. 1 Lexy hace un POST HTTP a tu webhook_url con el resultado del análisis.
  2. 2 Tu servidor debe responder con un status 2xx (200–299) dentro de 15 segundos.
  3. 3 Si no responde a tiempo, o responde con un status diferente, el envío queda como failed.
  4. 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 — evento exitoso: consulta.completed

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

Body JSON — evento de error: consulta.failed

json
{
  "event": "consulta.failed",
  "consulta_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "document_type_id": "550e8400-e29b-41d4-a716-446655440000",
  "error": {
    "category": "analysis",
    "message": "Error en el modelo de reconocimiento de documentos"
  },
  "timestamp": "2024-01-15T10:30:00.000Z"
}
CAMPOTIPODESCRIPCIÓN
eventstring"consulta.completed" o "consulta.failed"
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
analysisobjectSolo en completed: resultado del análisis
error.categorystringupload | analysis | credits | unknown
error.messagestringMensaje descriptivo del error
timestampstring (ISO 8601)Momento en que se completó o falló 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. 1Lee el body completo de la solicitud como string (bytes crudos, no parseado).
  2. 2Calcula HMAC-SHA256(body, shared_secret) y conviértelo a hex.
  3. 3Compara 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
  }
}
⚠️
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.

4xx 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..."Body no es multipartUsa Content-Type: multipart/form-data
"Solo se permiten URLs HTTPS"URL con HTTP en lugar de HTTPSUsa una URL HTTPS
"Tiempo de espera agotado al descargar..."Descarga tardó más de 30sVerifica que la URL sea accesible

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
"No autorizado"En endpoints GET: header completamente ausenteAgrega el header Authorization

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 configuración de la app, o activa "Permitir todos los tipos"
404"Tipo de documento no encontrado o inactivo"Verifica el ID correcto en el panel → Tipos de documento
404"Consulta no encontrada"Verifica que el consulta_id sea correcto y uses la API Key de la misma organización

Troubleshooting

Mi webhook aparece como failed

  1. 1. Verifica la URL: asegúrate de que tu servidor sea accesible desde internet (no localhost).
  2. 2. Revisa el response status: tu endpoint debe retornar 2xx.
  3. 3. Revisa el timeout: si tu procesamiento demora más de 15s, responde con 200 OK inmediatamente y procesa en background.
  4. 4. Revisa los logs: el panel de Lexy muestra la respuesta de tu servidor en el detalle del log.

El analysis viene vacío o con null

Esto puede ocurrir si: el documento está en mal estado (baja calidad, ilegible, rotado), el tipo de documento no coincide con el archivo enviado, o los campos del tipo de documento no coinciden con el contenido del documento.

No se descuenta crédito si el análisis retorna datos vacíos.

Ejemplos completos

Integraciones end-to-end: desde enviar el documento hasta procesar el resultado del webhook.

Variables de entorno requeridas

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

Node.js + Express

Integración completa con Express, verificación de firma e idempotencia.

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;

const procesadas = new Set(); // en producción usa Redis o base de datos

// ─── 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();
  return consulta_id;
}

// ─── Webhook endpoint ─────────────────────────────────────────
app.post('/webhook/lexy', express.raw({ type: 'application/json' }), (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)
    .digest('hex');

  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return res.status(401).json({ error: 'Firma inválida' });
  }

  const payload = JSON.parse(req.body.toString());
  const { consulta_id, analysis } = payload;

  if (procesadas.has(consulta_id)) {
    return res.status(200).json({ received: true });
  }
  procesadas.add(consulta_id);

  res.status(200).json({ received: true });
  setImmediate(() => {
    console.log('[Lexy] Análisis recibido:', JSON.stringify(analysis, null, 2));
  });
});

app.listen(3000);

Python + FastAPI

main.py
import hashlib, hmac, 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()
consultas_procesadas: set[str] = set()

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:
        raise Exception(f"Error {response.status_code}: {response.json().get('error')}")
    return response.json()["consulta_id"]

@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()
    consulta_id = payload["consulta_id"]

    if consulta_id in consultas_procesadas:
        return JSONResponse({"received": True})

    consultas_procesadas.add(consulta_id)
    print(f"[Lexy] Análisis: {payload['analysis']}")
    return JSONResponse({"received": True})

PHP + Laravel

LexyWebhookController.php
<?php
namespace AppHttpControllers;

use IlluminateHttpRequest;
use IlluminateHttpJsonResponse;

class LexyWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        $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);
        }

        $payload = $request->json()->all();
        $consultaId = $payload['consulta_id'];

        $cacheKey = "lexy_webhook_{$consultaId}";
        if (cache()->has($cacheKey)) {
            return response()->json(['received' => true]);
        }
        cache()->put($cacheKey, true, now()->addDays(7));

        ProcessLexyAnalysis::dispatch($consultaId, $payload['analysis']);
        return response()->json(['received' => true]);
    }
}

Simular un webhook para testing

bash
# Genera la firma correcta y simula 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 integración

Verifica estos puntos antes de ir a producción.

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

Manejas tanto el evento consulta.completed como consulta.failed.