# Webhooks

> Reciba los eventos analysis.completed, analysis.failed y balance.low. Verificación de firmas de Standard Webhooks en JavaScript y Python, reintentos, desactivación automática y el evento de prueba.

Fuente: https://fileverity.com/es/docs/webhooks

Los webhooks envían una solicitud HTTPS a su servidor cuando ocurre algo, para que no tenga que consultar la API periódicamente. Siguen la especificación [Standard Webhooks](https://www.standardwebhooks.com).

Añada endpoints en la Consola, en **Webhooks**. Elija la URL y los eventos que recibe. El secreto de firma, que empieza por `whsec_`, se muestra una sola vez al crear el endpoint. Puede cambiar los eventos de un endpoint en cualquier momento sin cambiar su secreto.

Para sustituir un secreto perdido o expuesto, elija **Renovar secreto** en el endpoint. El secreto nuevo se muestra una sola vez y el anterior deja de funcionar de inmediato, así que actualice enseguida el código con el que verifica las firmas.

- La URL debe usar HTTPS en el puerto predeterminado 443 y apuntar a una dirección pública. Se rechazan las URL con credenciales o con otro puerto.
- Un espacio de trabajo puede tener hasta 10 endpoints.
- Las claves de prueba nunca envían eventos.

## Eventos

| Evento | Se envía cuando |
| --- | --- |
| `analysis.completed` | Un análisis de imagen o de documento se completó correctamente. |
| `analysis.failed` | Un análisis falló o se canceló. No se cobró. Un análisis cancelado tiene `status: canceled` y `error: canceled`. |
| `balance.low` | El saldo cayó por debajo del umbral de alerta que fijó en la Consola. |

Los tipos de evento admitidos son `analysis.completed`, `analysis.failed`, `balance.low`.

## Contenido de los eventos

Todos los eventos tienen la misma estructura: `id`, `object` (siempre `event`), `type`, `created` (marca de tiempo Unix) y `data`. El `id` también se envía en el encabezado `webhook-id`.

### analysis.completed y analysis.failed

`data` es el mismo objeto de análisis que devuelve `GET /analyses/{id}`, con los mismos nombres de campo y tipos, salvo que `result` es siempre `null`: obtenga el resultado con ese endpoint. `status` es `succeeded`, `failed` o `canceled`, y `error` es un objeto con `code` y `message`, o `null`. Las marcas de tiempo se expresan en segundos Unix.

**analysis.completed**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": "FV-7K2M-9QXD-4T8R",
    "report_url": "https://fileverity.com/verify/FV-7K2M-9QXD-4T8R",
    "summary": {
      "level": "L2",
      "assessment": "authentic_untouched",
      "action": "accept",
      "ai": "very_low",
      "edit": "none"
    },
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931
  }
}
```

**analysis.failed**

```json
{
  "id": "evt_Zc4vB7nP1sK8tL3wR6yQm2Xa",
  "object": "event",
  "type": "analysis.failed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "failed",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": {
      "code": "analysis_failed",
      "message": "The analysis could not be completed. You were not charged; retry it, and contact support with the request ID if it fails again."
    },
    "progress": null,
    "usage": {
      "amount": 0,
      "currency": "usd",
      "funding": null
    },
    "metadata": null,
    "completed_at": 1793700135,
    "expires_at": null
  }
}
```

### balance.low

`balance` y `threshold` están en centavos de dólar estadounidense.

**balance.low**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "balance.low",
  "created": 1793700500,
  "data": {
    "balance": 640,
    "threshold": 1000
  }
}
```

## Verificar la firma

Cada solicitud incluye tres encabezados, según define la especificación Standard Webhooks:

| Encabezado | Valor |
| --- | --- |
| `webhook-id` | El ID del evento. Es el mismo en todos los reintentos; úselo para ignorar duplicados. |
| `webhook-timestamp` | Hora Unix, en segundos, de este intento de envío. Cambia en cada reintento. |
| `webhook-signature` | `v1,` seguido de un HMAC-SHA256 en base64. Un encabezado puede incluir varias firmas separadas por espacios. |

Para verificar una solicitud:

1. Lea el cuerpo sin procesar de la solicitud como texto, antes de analizar el JSON.
2. Construya el contenido firmado: `webhook-id`, un punto, `webhook-timestamp`, un punto y el cuerpo sin procesar.
3. Tome su secreto de firma, quite el prefijo `whsec_` y decodifique el resto desde base64. Esa es la clave HMAC.
4. Calcule el HMAC-SHA256 del contenido firmado y codifíquelo en base64.
5. Compárelo, en tiempo constante, con cada firma `v1` del encabezado.
6. Rechace las marcas de tiempo que difieran en más de cinco minutos de su reloj.

**Verificar un webhook: JavaScript**

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("Timestamp outside tolerance");
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(id + "." + timestamp + "." + body).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("Invalid signature");
  return JSON.parse(body);
}

app.post("/webhooks/fileverity", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verify(process.env.FILEVERITY_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
    handle(event);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});
```

**Verificar un webhook: Python**

```python
import base64
import binascii
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

def verify(secret, headers, body):
    message_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = (message_id + "." + timestamp + ".").encode() + body
    expected = hmac.new(key, signed, hashlib.sha256).digest()
    for entry in headers["webhook-signature"].split(" "):
        version, _, value = entry.partition(",")
        try:
            given = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if version == "v1" and hmac.compare_digest(given, expected):
            return json.loads(body)
    raise ValueError("Invalid signature")

@app.post("/webhooks/fileverity")
async def receive(request: Request):
    body = await request.body()
    try:
        event = verify(os.environ["FILEVERITY_WEBHOOK_SECRET"], request.headers, body)
    except (KeyError, ValueError):
        raise HTTPException(status_code=400)
    handle(event)
    return {"ok": True}
```

> **Nota.** Verifique con los bytes exactos que se enviaron. Analizar el JSON y volver a serializarlo los modifica e invalida la firma.

## Envío y reintentos

- Responda con cualquier estado `2xx` en un plazo de 10 segundos. No se siguen las redirecciones y se cuentan como fallos.
- Cualquier otra respuesta, incluidos un tiempo de espera agotado o un error de conexión, programa un reintento con esperas crecientes: 5, 10, 20, 40, 80, 160, 320 y 640 minutos y, después, cada 12 horas. Los reintentos terminan 3 días después del evento. Los reintentos se ejecutan de forma programada, así que un reintento puede llegar más tarde que su espera, nunca antes.
- Los eventos se envían al menos una vez y no se garantiza el orden. Use `webhook-id` para ignorar duplicados y obtenga el análisis si necesita el estado más reciente.
- La Consola muestra los últimos 50 envíos con su estado y sus errores, los conserva 30 días y permite reenviar cualquiera de ellos. Abra un envío para ver el contenido de la solicitud, el tiempo de respuesta y los primeros 2 KB de la respuesta de su endpoint, así que no devuelva secretos ni datos personales en esa respuesta. Un reenvío conserva el ID del evento original.
- Las solicitudes llevan el agente de usuario `Fileverity-Webhooks/1.0`.

### Desactivación automática

Un endpoint que falla de forma continua durante 3 días se desactiva. Entonces deja de recibir eventos nuevos y sus eventos no entregados se marcan como fallidos. Un envío correcto antes de ese plazo borra el fallo. Se avisa por correo a los propietarios y administradores del espacio de trabajo cuando se desactiva un endpoint. Vuelva a activar el endpoint en la Consola cuando lo haya corregido.

## El evento de prueba

Use **Enviar prueba** en un endpoint de la Consola para enviar de inmediato un evento de ejemplo. Es un evento `analysis.completed` firmado, con `data.test` establecido en `true` y sin informe, de modo que pueda comprobar su URL y su código de verificación de firmas antes de que lleguen eventos reales.

**Evento de prueba**

```json
{
  "id": "evt_Qm4vB7nP1sK8tL3wR6yZ2cXa",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700500,
  "data": {
    "id": "an_Pw3mY6zB8Hk2pQ4vN9sL1xR7",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931,
    "test": true
  }
}
```

Relacionado: [Análisis](https://fileverity.com/es/docs/analyses.md), [Idempotencia](https://fileverity.com/es/docs/idempotency.md) y [Errores](https://fileverity.com/es/docs/errors.md).

