# Webhooks

Los webhooks de Frihet permiten recibir notificaciones HTTP en tiempo real cuando ocurren eventos en tu cuenta. En lugar de hacer polling a la API, configura una URL y Frihet enviara un POST con los datos del evento en el momento en que se produzca.

## Configuracion

Puedes gestionar los webhooks desde el panel de Frihet (**Configuracion > Webhooks**) o de forma programatica a traves de la API REST.

### Desde el panel

1. Ve a **Configuracion > Webhooks** en tu cuenta de Frihet
2. Pulsa **Crear webhook**
3. Introduce un nombre descriptivo, la URL de destino y selecciona los eventos que quieres recibir
4. Opcionalmente, define un **secreto** para la verificacion HMAC (recomendado)
5. Guarda el webhook

### API REST de webhooks

Gestiona webhooks de forma programatica con los endpoints CRUD completos.

#### Listar webhooks

```
GET /v1/webhooks
```

```bash
curl https://api.frihet.io/v1/webhooks \
  -H "X-API-Key: fri_tu-clave-aqui"
```

**Respuesta (200):**

```json
{
  "data": [
    {
      "id": "wh_abc123",
      "url": "https://mi-app.com/hook",
      "events": ["invoice.paid", "invoice.created"],
      "active": true,
      "createdAt": "2026-02-01T10:00:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
```

#### Obtener webhook

```
GET /v1/webhooks/:id
```

```bash
curl https://api.frihet.io/v1/webhooks/wh_abc123 \
  -H "X-API-Key: fri_tu-clave-aqui"
```

#### Crear webhook

```
POST /v1/webhooks
```

| Campo    | Tipo     | Requerido | Descripcion                                      |
| -------- | -------- | --------- | ------------------------------------------------ |
| `url`    | string   | Si        | URL de destino (HTTPS obligatorio en produccion) |
| `events` | string[] | Si        | Lista de tipos de evento a recibir               |
| `secret` | string   | No        | Secreto para firma HMAC-SHA256 (recomendado)     |
| `active` | boolean  | No        | Estado del webhook (defecto: `true`)             |

```bash
curl -X POST https://api.frihet.io/v1/webhooks \
  -H "X-API-Key: fri_tu-clave-aqui" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://mi-app.com/hook",
    "events": ["invoice.paid", "expense.created"],
    "secret": "mi-secreto-hmac"
  }'
```

**Respuesta (201):**

```json
{
  "id": "wh_def456",
  "url": "https://mi-app.com/hook",
  "events": ["invoice.paid", "expense.created"],
  "active": true,
  "createdAt": "2026-03-18T10:00:00.000Z"
}
```

#### Actualizar webhook

```
PATCH /v1/webhooks/:id
```

Solo necesitas enviar los campos que quieras modificar.

```bash
curl -X PATCH https://api.frihet.io/v1/webhooks/wh_def456 \
  -H "X-API-Key: fri_tu-clave-aqui" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["invoice.paid", "invoice.created", "expense.created"],
    "active": false
  }'
```

**Respuesta (200):** Objeto de webhook actualizado.

#### Eliminar webhook

```
DELETE /v1/webhooks/:id
```

```bash
curl -X DELETE https://api.frihet.io/v1/webhooks/wh_def456 \
  -H "X-API-Key: fri_tu-clave-aqui"
```

**Respuesta:** `204 No Content`

---

**Requisitos de la URL:**

- Debe usar HTTPS (se permite HTTP solo para `localhost` y `127.0.0.1` durante el desarrollo)
- No se permiten IPs privadas (10.x, 172.16-31.x, 192.168.x) para evitar ataques SSRF
- Debe responder con un codigo 2xx en menos de 30 segundos

**Limites:**

- Maximo **20 webhooks** por cuenta
- Payload maximo de **100 KB** por entrega

---

## Tipos de evento

Frihet emite **27 tipos de evento**, agrupados por recurso.

### Facturas (9 eventos)

| Evento                           | Descripcion                                                                 |
| -------------------------------- | --------------------------------------------------------------------------- |
| `invoice.created`                | Se ha creado una nueva factura                                              |
| `invoice.updated`                | Se ha modificado una factura existente                                      |
| `invoice.generated`              | Se ha generado una factura automaticamente (recurrente o desde presupuesto) |
| `invoice.one_off_created`        | Se ha creado una factura puntual (no recurrente)                            |
| `invoice.paid`                   | Una factura ha sido marcada como pagada (completa)                          |
| `invoice.overdue`                | Una factura ha superado su fecha de vencimiento                             |
| `invoice.voided`                 | Una factura ha sido anulada                                                 |
| `invoice.payment_status_updated` | El estado de pago ha cambiado (parcial, fallido, reembolsado)               |
| `invoice.payment_failure`        | Un intento de cobro automatico ha fallado                                   |

### Pagos (3 eventos)

| Evento                    | Descripcion                                                          |
| ------------------------- | -------------------------------------------------------------------- |
| `payment.succeeded`       | Un pago se ha completado correctamente (Stripe, portal de cliente)   |
| `payment.requires_action` | Un pago requiere accion adicional (3D Secure, confirmacion bancaria) |
| `payment_receipt.created` | Se ha generado un recibo de pago                                     |

### Facturas rectificativas (2 eventos)

| Evento                  | Descripcion                                      |
| ----------------------- | ------------------------------------------------ |
| `credit_note.created`   | Se ha creado una factura rectificativa           |
| `credit_note.generated` | Se ha generado automaticamente una rectificativa |

### Presupuestos (5 eventos)

| Evento           | Descripcion                                                            |
| ---------------- | ---------------------------------------------------------------------- |
| `quote.created`  | Se ha creado un nuevo presupuesto                                      |
| `quote.updated`  | Se ha modificado un presupuesto existente                              |
| `quote.accepted` | Un cliente ha aceptado un presupuesto (cambio de estado a `accepted`)  |
| `quote.rejected` | Un cliente ha rechazado un presupuesto (cambio de estado a `rejected`) |
| `quote.expired`  | Un presupuesto ha superado su fecha de validez                         |

### Gastos (2 eventos)

| Evento            | Descripcion                         |
| ----------------- | ----------------------------------- |
| `expense.created` | Se ha registrado un nuevo gasto     |
| `expense.updated` | Se ha modificado un gasto existente |

### Clientes (3 eventos)

| Evento              | Descripcion                                                               |
| ------------------- | ------------------------------------------------------------------------- |
| `client.created`    | Se ha dado de alta un nuevo cliente                                       |
| `client.updated`    | Se han modificado los datos de un cliente                                 |
| `client.vies_check` | Se ha verificado el NIF-IVA de un cliente contra el sistema VIES de la UE |

### Productos (2 eventos)

| Evento            | Descripcion                                       |
| ----------------- | ------------------------------------------------- |
| `product.created` | Se ha creado un nuevo producto o servicio         |
| `product.updated` | Se ha modificado un producto o servicio existente |

### Cobros (1 evento)

| Evento             | Descripcion                                                             |
| ------------------ | ----------------------------------------------------------------------- |
| `dunning.finished` | El proceso de cobro automatico (dunning) ha finalizado para una factura |

---

## Estructura del payload

Cada entrega de webhook es una peticion `POST` con cuerpo JSON. La estructura es la misma para todos los tipos de evento:

```json
{
  "event": "invoice.paid",
  "timestamp": "2026-02-12T14:30:00.000Z",
  "data": {
    "id": "inv_abc123",
    "clientName": "Acme S.L.",
    "items": [{ "description": "Consultoria", "quantity": 10, "unitPrice": 75 }],
    "total": 750,
    "status": "paid",
    "paidAt": "2026-02-12T14:29:58.000Z",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-02-12T14:30:00.000Z"
  }
}
```

El campo `data` contiene el estado completo del recurso en el momento del evento.

---

## Cabeceras de entrega

Cada peticion de webhook incluye las siguientes cabeceras:

| Cabecera               | Descripcion                       | Ejemplo                    |
| ---------------------- | --------------------------------- | -------------------------- |
| `Content-Type`         | Tipo de contenido                 | `application/json`         |
| `X-Frihet-Event`       | Tipo de evento                    | `invoice.paid`             |
| `X-Frihet-Delivery-Id` | Identificador unico de la entrega | `d4e5f6a7b8c9`             |
| `X-Frihet-Timestamp`   | Marca de tiempo ISO 8601          | `2026-02-12T14:30:00.000Z` |
| `X-Frihet-Signature`   | Firma HMAC-SHA256 del payload     | `sha256=a1b2c3d4e5f6...`   |

La cabecera `X-Frihet-Signature` solo se incluye si has configurado un secreto en el webhook.

---

## Verificacion de firma

Si configuras un secreto al crear el webhook, Frihet firma cada payload con HMAC-SHA256. El proceso de verificacion consiste en:

1. Tomar el cuerpo de la peticion en bruto (raw body)
2. Calcular el HMAC-SHA256 usando el secreto como clave
3. Comparar el resultado con el valor de la cabecera `X-Frihet-Signature` (sin el prefijo `sha256=`)

### Con el SDK (recomendado)

```typescript

app.post('/webhook/frihet', express.raw({ type: 'application/json' }), (req, res) => {
  const isValid = Webhooks.verifySignature(
    req.body,
    req.headers['x-frihet-signature'],
    process.env.FRIHET_WEBHOOK_SECRET,
  );

  if (!isValid) return res.status(401).send('Firma invalida');

  const event = JSON.parse(req.body.toString());
  console.log(req.headers['x-frihet-event'], event.data);
  res.sendStatus(200);
});
```

### Ejemplo manual en Node.js

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signature, secret) {
  const expectedSignature = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const receivedSignature = signature.replace('sha256=', '');

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'hex'),
    Buffer.from(receivedSignature, 'hex'),
  );
}

// Uso en un servidor Express
app.post('/webhook/frihet', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-frihet-signature'];
  const secret = process.env.FRIHET_WEBHOOK_SECRET;

  if (!signature || !verifyWebhookSignature(req.body.toString(), signature, secret)) {
    return res.status(401).send('Firma invalida');
  }

  const event = JSON.parse(req.body.toString());
  const eventType = req.headers['x-frihet-event'];

  console.log(`Evento recibido: ${eventType}`, event.data);

  // Procesar el evento segun su tipo
  switch (eventType) {
    case 'invoice.paid':
      // Actualizar tu sistema de contabilidad
      break;
    case 'expense.created':
      // Notificar al equipo financiero
      break;
  }

  res.status(200).send('OK');
});
```

### Ejemplo en Python

```python
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = 'tu-secreto-aqui'

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    received = signature.replace('sha256=', '')

    return hmac.compare_digest(expected, received)

@app.route('/webhook/frihet', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Frihet-Signature', '')
    if not verify_signature(request.data, signature, WEBHOOK_SECRET):
        abort(401)

    event = request.get_json()
    event_type = request.headers.get('X-Frihet-Event')

    print(f'Evento recibido: {event_type}')

    if event_type == 'invoice.paid':
        # Actualizar tu sistema de contabilidad
        pass
    elif event_type == 'quote.accepted':
        # Convertir presupuesto a factura
        pass

    return 'OK', 200
```

**Importante:** Usa siempre una comparacion en tiempo constante (`timingSafeEqual` en Node.js, `compare_digest` en Python) para evitar ataques de timing.

---

## Politica de reintentos

Si la entrega de un webhook falla (codigo de respuesta no-2xx o timeout), Frihet reintenta automaticamente con **backoff exponencial**:

| Intento               | Retardo    | Tiempo acumulado |
| --------------------- | ---------- | ---------------- |
| 1 (inicial)           | inmediato  | 0s               |
| 2 (primer reintento)  | 2 segundos | 2s               |
| 3 (segundo reintento) | 4 segundos | 6s               |

- Maximo **3 intentos** por entrega (1 inicial + 2 reintentos)
- Retardo maximo de **30 segundos** entre reintentos
- Timeout de **30 segundos** por peticion
- Si los 3 intentos fallan, la entrega se marca como `failed`
- En cada reintento se incluye la cabecera `X-Frihet-Retry-Attempt` con el numero de intento

Los reintentos se procesan mediante un job que se ejecuta cada 5 minutos (hasta 50 entregas pendientes por ejecucion), garantizando la fiabilidad incluso si el proceso principal se reinicia.

### Circuit breaker

Si un webhook acumula **3 fallos consecutivos**, Frihet lo pausa automaticamente para proteger tanto tu endpoint como el sistema de entregas.

- El webhook pasa a estado `paused` con `pausedReason: "consecutive_failures"`
- No se envian mas entregas hasta que lo reactives manualmente
- Puedes reactivar el webhook desde el panel o via API (`PATCH /v1/webhooks/:id` con `active: true`)

Puedes consultar el **historial de entregas** de cada webhook desde el panel de Frihet, incluyendo el codigo de respuesta, el cuerpo de la respuesta (primeros 1000 caracteres) y los errores de cada intento.

---

## Pruebas

Desde el panel de Frihet puedes enviar un **evento de prueba** a cualquier webhook configurado. El payload de prueba tiene la siguiente estructura:

```json
{
  "event": "webhook.test",
  "timestamp": "2026-02-12T14:30:00.000Z",
  "data": {
    "message": "This is a test webhook from Frihet ERP"
  }
}
```

Esto permite verificar que tu endpoint esta accesible, que la firma se valida correctamente y que tu sistema procesa los eventos sin errores.

---

## Buenas practicas

### Responde rapido

Tu endpoint debe responder con un codigo `200` lo antes posible. Si necesitas realizar un procesamiento pesado (enviar emails, actualizar bases de datos externas, etc.), acepta el evento y procesalo de forma asincrona en una cola de trabajo.

### Gestiona la idempotencia

Es posible que un mismo evento se entregue mas de una vez (por ejemplo, si tu servidor respondio con un timeout pero proceso el evento). Usa el campo `X-Frihet-Delivery-Id` como clave de idempotencia para evitar duplicados.

### Verifica la firma siempre

Nunca confies en un webhook sin verificar la cabecera `X-Frihet-Signature`. Cualquier actor con acceso a tu URL podria enviar payloads falsos.

### Usa HTTPS

En produccion, tu endpoint debe estar protegido con HTTPS. Frihet rechaza URLs HTTP (excepto en desarrollo local).

### Monitoriza los fallos

Revisa periodicamente los logs de entrega en el panel de Frihet. Si ves entregas fallidas de forma recurrente, verifica que tu endpoint esta disponible y responde en menos de 30 segundos.

### Filtra los eventos

Suscribete solo a los eventos que necesitas. Cada webhook puede escuchar uno o varios tipos de evento. Cuantos menos eventos innecesarios proceses, menor sera la carga en tu servidor.

---

## Depuracion de entregas

Frihet registra el resultado de cada entrega de webhook. Puedes consultarlo desde el panel:

1. Ve a **Ajustes > Desarrolladores > Webhooks**
2. Pulsa sobre el webhook que quieres inspeccionar
3. Abre la pestana **Entregas**

Cada entrada muestra:

- **Codigo de respuesta** HTTP devuelto por tu endpoint
- **Tiempo de respuesta** en milisegundos
- **Cuerpo de la respuesta** (primeros 1000 caracteres)
- **Fecha y hora** de cada intento (incluidos los reintentos)
- **Estado** final: `delivered`, `retrying` o `failed`

### Problemas habituales

| Sintoma                               | Causa probable                                | Solucion                                                             |
| ------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------- |
| Todas las entregas fallan con timeout | Endpoint tarda mas de 30s en responder        | Acepta el evento con un `200` inmediato y procesa de forma asincrona |
| Error SSL/TLS                         | Certificado expirado o cadena incompleta      | Renueva el certificado y verifica la cadena con `openssl s_client`   |
| Codigo 403 sistemático                | Firewall bloqueando peticiones POST entrantes | Permite trafico desde las IPs de Google Cloud (`us-central1`)        |
| Codigo 502/503                        | Servidor caido o en mantenimiento             | Revisa los logs de tu servidor y verifica que el proceso esta activo |

### Boton de prueba

En la pantalla de detalle de cada webhook, el boton **Enviar prueba** envia un evento sintetico `webhook.test` a tu URL. Usa este boton para verificar:

- Que la URL es accesible desde internet
- Que la firma HMAC se valida correctamente
- Que tu servidor responde con un codigo `2xx`

El resultado de la prueba aparece inmediatamente en el log de entregas.

---

## Resolucion de problemas

### No recibo webhooks

1. Verifica que la URL del webhook es correcta y accesible desde internet
2. Comprueba que el webhook esta en estado **activo** en el panel
3. Revisa los logs de entrega para ver si hay errores
4. Si usas un firewall, asegurate de que las peticiones POST entrantes desde Google Cloud (`us-central1`) estan permitidas

### Las firmas no coinciden

1. Verifica que estas usando el raw body de la peticion (antes de parsear el JSON)
2. Confirma que el secreto en tu codigo coincide con el configurado en el panel de Frihet
3. No modifiques ni reformatees el body antes de verificar la firma
4. Revisa que tu framework no esta parseando automaticamente el body antes de que puedas acceder al raw

### Los reintentos no llegan

1. Los reintentos se procesan cada 5 minutos. Si tu endpoint estuvo caido brevemente, puede que ya se hayan agotado los intentos
2. Revisa el historial de entregas para confirmar el estado de cada intento
3. Si los 3 intentos fallaron, la entrega no se reintentara de nuevo automaticamente

### Payload demasiado grande

Si el recurso asociado al evento es muy grande (muchas lineas en una factura, campos extensos), el payload puede superar el limite de 100 KB y la entrega sera rechazada. Simplifica los datos del recurso o contacta con soporte si necesitas un limite superior.
