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.
POST /api/ingest202 { consulta_id }POST tu-webhook-url200 OK confirma recepciónPuntos clave
- ✓ La respuesta
202llega 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
2xxpara 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.
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
- 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_keyyshared_secretuna sola vez en pantalla. - 4 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_KEYEjemplo 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.
/api/ingest 202 AcceptedRequest
Opción 1: Subir archivo directamente (multipart/form-data)
| ATRIBUTO | VALOR |
|---|---|
| Método | POST |
| URL | https://app.holalexy.com/api/ingest |
| Content-Type | multipart/form-data |
| Autenticación | Authorization: Bearer <api_key> |
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 |
Opción 2: Descargar desde URL (application/json)
{
"file_url": "https://example.com/documents/invoice.pdf",
"document_type_id": "550e8400-e29b-41d4-a716-446655440000"
}| CAMPO | TIPO | REQUERIDO | DESCRIPCIÓN |
|---|---|---|---|
file_url | string (HTTPS URL) | Requerido | URL HTTPS del documento a descargar |
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:
- 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
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"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
| 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" | 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.
/api/consultas/{consulta_id}Estado individual
| ATRIBUTO | VALOR |
|---|---|
| Método | GET |
| URL | https://app.holalexy.com/api/consultas/{consulta_id} |
| Autenticación | Authorization: Bearer <api_key> |
Respuesta — análisis en curso:
{
"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:
{
"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:
{
"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"
}
}| CAMPO | TIPO | DESCRIPCIÓN |
|---|---|---|
consulta_id | string (UUID) | ID único de la consulta |
status | string | pending | completed | failed |
document_type_id | string (UUID) | ID del tipo de documento analizado |
application_id | string (UUID) | ID de la aplicación que envió el documento |
created_at | string (ISO 8601) | Fecha y hora en que se recibió el documento |
analysis | object | Solo en completed: resultado del análisis |
error.message | string | Solo en failed: mensaje descriptivo del error |
/api/consultasLista con filtros
Retorna una lista paginada de consultas de tu organización, con soporte para filtrar por estado.
Query params (todos opcionales)
| PARÁMETRO | TIPO | POR DEFECTO | DESCRIPCIÓN |
|---|---|---|---|
status | string | — | Filtrar: pending | completed | failed |
limit | integer | 20 | Cantidad de resultados por página. Máximo 100 |
offset | integer | 0 | Número de resultados a saltar (paginación) |
{
"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:
# Página 1
GET /api/consultas?limit=20&offset=0
# Página 2
GET /api/consultas?limit=20&offset=20Polling con 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 Lexy hace un
POSTHTTP a tuwebhook_urlcon 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
Content-Type: application/json
x-lexy-signature: <hmac-sha256-hex>Body JSON — evento exitoso: consulta.completed
{
"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
{
"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"
}| CAMPO | TIPO | DESCRIPCIÓN |
|---|---|---|
event | string | "consulta.completed" o "consulta.failed" |
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 | Solo en completed: resultado del análisis |
error.category | string | upload | analysis | credits | unknown |
error.message | string | Mensaje descriptivo del error |
timestamp | string (ISO 8601) | Momento en que se completó o falló 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
- 1Lee el body completo de la solicitud como string (bytes crudos, no parseado).
- 2Calcula
HMAC-SHA256(body, shared_secret)y conviértelo a hex. - 3Compara 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
}
}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 3000Usa 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..." | Body no es multipart | Usa Content-Type: multipart/form-data |
"Solo se permiten URLs HTTPS" | URL con HTTP en lugar de HTTPS | Usa una URL HTTPS |
"Tiempo de espera agotado al descargar..." | Descarga tardó más de 30s | Verifica que la URL sea accesible |
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 |
"No autorizado" | En endpoints GET: header completamente ausente | Agrega el header Authorization |
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 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. Verifica la URL: asegúrate de que tu servidor sea accesible desde
internet (no
localhost). - 2. Revisa el response status: tu endpoint debe retornar
2xx. - 3. Revisa el timeout: si tu procesamiento demora más de 15s, responde con
200 OKinmediatamente y procesa en background. - 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
# .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-446655440000Node.js + Express
Integración completa con Express, verificación de firma e idempotencia.
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
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
<?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
# 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.