# Webhooks salientes

> Catálogo de eventos, formato de las solicitudes, verificación de firma y política de entregas de los webhooks salientes de Probara.

Los webhooks salientes envían una solicitud HTTP a tu endpoint cuando ocurre algo en un proyecto. Esta página es la referencia para quien recibe las solicitudes: qué eventos existen, qué contiene cada solicitud, cómo verificarla y cómo reintenta Probara. Para configurar un webhook en la aplicación, lee la [guía de webhooks](https://docs.probara.net/es/guides/webhooks/). Para gestionar webhooks desde código, lee la [API de webhooks](https://docs.probara.net/es/developers/api/webhooks/).

## Ruta rápida

1. Expón un endpoint `https://` que acepte el método configurado (`POST` por defecto).
2. Verifica `webhook-signature` contra el cuerpo sin procesar de la solicitud, con tu secreto. Los pasos están en Verificar la firma, más abajo.
3. Responde con un estado `2xx` en 10 segundos y, después, realiza el trabajo lento.
4. Omite los eventos que ya procesaste, usando `webhook-id`. Probara puede enviar el mismo evento más de una vez.

## Catálogo de eventos

Cada evento se llama `entidad.acción`. Probara no renombra ni reutiliza un nombre de evento, así que tu receptor puede usarlos para identificar eventos. Ignora los nombres que no maneje.

| Evento                       | Se envía cuando                                                                                                                                                |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test_case.created`          | Se crea un caso de prueba o se clona uno a uno.                                                                                                                |
| `test_case.updated`          | Se edita o mueve un caso de prueba.                                                                                                                            |
| `test_case.archived`         | Se archiva un caso de prueba.                                                                                                                                  |
| `test_case.restored`         | Se restaura un caso de prueba archivado.                                                                                                                       |
| `test_case.deleted`          | Se elimina un caso de prueba.                                                                                                                                  |
| `test_case.commented`        | Se añade un comentario a un caso de prueba.                                                                                                                    |
| `suite.created`              | Se crea o clona una suite.                                                                                                                                     |
| `suite.updated`              | Se edita o mueve una suite.                                                                                                                                    |
| `suite.deleted`              | Se elimina una suite.                                                                                                                                          |
| `test_plan.created`          | Se crea un plan de prueba.                                                                                                                                     |
| `test_plan.updated`          | Se edita un plan de prueba.                                                                                                                                    |
| `test_plan.status_changed`   | Cambia el estado de un plan de prueba.                                                                                                                         |
| `test_plan.deleted`          | Se elimina un plan de prueba.                                                                                                                                  |
| `milestone.created`          | Se crea un hito.                                                                                                                                               |
| `milestone.updated`          | Se edita un hito.                                                                                                                                              |
| `milestone.status_changed`   | Cambia el estado de un hito.                                                                                                                                   |
| `milestone.deleted`          | Se elimina un hito.                                                                                                                                            |
| `test_run.created`           | Se crea o clona una ejecución.                                                                                                                                 |
| `test_run.updated`           | Se edita una ejecución.                                                                                                                                        |
| `test_run.closed`            | Se cierra una ejecución.                                                                                                                                       |
| `test_run.aborted`           | Se aborta una ejecución.                                                                                                                                       |
| `test_run.deleted`           | Se elimina una ejecución.                                                                                                                                      |
| `test_run.results_submitted` | Se registran resultados en masa en una ejecución, con una marca masiva, un envío masivo de resultados o un informe de CI. Un solo evento cubre toda la acción. |
| `result.created`             | Se marca un único caso en una ejecución.                                                                                                                       |
| `defect.created`             | Se crea un defecto.                                                                                                                                            |
| `defect.updated`             | Se edita un defecto.                                                                                                                                           |
| `defect.status_changed`      | Cambia el estado de un defecto, incluido cuando se reabre.                                                                                                     |
| `defect.commented`           | Se añade un comentario a un defecto.                                                                                                                           |
| `defect.deleted`             | Se elimina un defecto.                                                                                                                                         |
| `shared_step.created`        | Se crea un paso compartido.                                                                                                                                    |
| `shared_step.updated`        | Se edita un paso compartido.                                                                                                                                   |
| `shared_step.deleted`        | Se elimina un paso compartido.                                                                                                                                 |

Algunos cambios no envían ningún evento:

- Reabrir una ejecución.
- Los casos de prueba creados por una importación o una clonación masiva.
- Los casos de prueba que una sincronización desde Git crea o actualiza. La sincronización sí envía `test_case.archived` y `test_case.restored` por los casos que archiva y restaura.
- Los pasos compartidos que pertenecen a la organización y no a un proyecto.

## Envoltorio del evento

Cada evento tiene esta forma. La carga útil estándar es este objeto JSON, y las plantillas personalizadas pueden leer cualquiera de sus campos por ruta.

| Campo          | Tipo            | Significado                                                                                                                                                                                                                                                                                          |
| -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | ULID            | Identificador del evento. Se envía como `webhook-id`.                                                                                                                                                                                                                                                |
| `type`         | cadena          | Nombre del evento, del catálogo.                                                                                                                                                                                                                                                                     |
| `version`      | `1`             | Versión del envoltorio. Solo cambia ante un cambio incompatible en el propio envoltorio.                                                                                                                                                                                                             |
| `createdAt`    | cadena          | Momento en que se creó el evento, en ISO 8601 UTC.                                                                                                                                                                                                                                                   |
| `test`         | booleano        | `true` para una entrega enviada con **Enviar prueba**.                                                                                                                                                                                                                                               |
| `organization` | objeto          | `ulid` y `name` de la organización.                                                                                                                                                                                                                                                                  |
| `project`      | objeto          | `ulid`, `key` (el código del proyecto) y `name` del proyecto.                                                                                                                                                                                                                                        |
| `actor`        | objeto          | `type` (`user`, `api_token` o `system`), `ulid` y `name`. Una acción hecha con un token de API personal tiene a su dueño como actor `user`; `api_token` es un token de aplicación de la organización. `ulid` y `name` son `null` en `system`, y pueden ser `null` cuando un usuario no tiene nombre. |
| `entity`       | objeto          | `type` (el grupo del elemento, como `test_case`), `ulid`, `displayId`, `label` y `url`. `displayId` es `null` en los elementos que no tienen uno. `url` es siempre `null` por ahora.                                                                                                                 |
| `data`         | objeto o `null` | La entidad tal como la devuelve la API pública, leída al procesar el evento (normalmente unos segundos después del cambio). `null` en una eliminación, o si la entidad ya no existe en ese momento.                                                                                                  |
| `changes`      | objeto o `null` | En una actualización, cada campo modificado con sus valores `from` y `to`. `null` cuando el evento no registra cambios.                                                                                                                                                                              |

En `test_run.results_submitted`, `data` incluye además un objeto `summary`:

- `source` es `report` en un informe de CI, y `null` en las demás acciones masivas.
- `results` es el número de resultados que registró un informe de CI, y `null` en las demás acciones masivas.
- `cases` contiene los detalles de la acción masiva: `summary`, con los recuentos `affected` y `unchanged`, y `cases`, con los casos listados. `omittedCount` aparece cuando la lista se recortó.

Ejemplo de un evento de actualización de un caso de prueba. El objeto `data` está abreviado:

```json
{
  "id": "01JC8Z4K9P2Q7R5T1V3W6X8Y0A",
  "type": "test_case.updated",
  "version": 1,
  "createdAt": "2026-03-02T15:04:05.123Z",
  "test": false,
  "organization": { "ulid": "01JC8Z3M0N1P2Q3R4S5T6V7W8X", "name": "Acme" },
  "project": { "ulid": "01JC8Z3M1A2B3C4D5E6F7G8H9J", "key": "CHK", "name": "Checkout" },
  "actor": { "type": "user", "ulid": "01JC8Z3M2K3L4M5N6P7Q8R9S0T", "name": "Ada Lovelace" },
  "entity": {
    "type": "test_case",
    "ulid": "01JC8Z3M3U4V5W6X7Y8Z9A0B1C",
    "displayId": "TC-42",
    "label": "Pay with a saved card",
    "url": null
  },
  "data": {
    "ulid": "01JC8Z3M3U4V5W6X7Y8Z9A0B1C",
    "displayId": "TC-42",
    "title": "Pay with a saved card"
  },
  "changes": { "title": { "from": "Pay with a card", "to": "Pay with a saved card" } }
}
```

## Solicitud

Cada solicitud incluye estos encabezados. También se añaden los encabezados personalizados que configuraste en el webhook.

| Encabezado          | Valor                                                                                               |
| ------------------- | --------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json` para la carga útil estándar. En otro caso, el tipo de contenido de tu plantilla. |
| `User-Agent`        | `Probara-Webhooks/1.0`                                                                              |
| `webhook-id`        | El `id` del evento. Es el mismo en cada intento y en cada reenvío de ese evento.                    |
| `webhook-timestamp` | Tiempo Unix en segundos, en el momento en que se firmó este intento.                                |
| `webhook-signature` | `v1,` seguido de la firma HMAC-SHA256 codificada en base64.                                         |

## Verificar la firma

La firma es un HMAC-SHA256 del texto `<webhook-id>.<webhook-timestamp>.<cuerpo>`, donde `<cuerpo>` es el cuerpo sin procesar de la solicitud. La clave son los bytes del secreto: la parte posterior a `whsec_`, decodificada desde base64. El resultado se codifica en base64 y se envía después de `v1,`.

1. Lee el cuerpo sin procesar antes de analizarlo. Volver a serializar el JSON ya analizado produce otros bytes.
2. Rechaza la solicitud si falta alguno de los tres encabezados `webhook-*`.
3. Rechaza las marcas de tiempo fuera de tu tolerancia. Los ejemplos aceptan cinco minutos.
4. Calcula la firma esperada y compárala en tiempo constante con cada entrada `v1` de `webhook-signature`. El encabezado es una lista separada por espacios, así que acepta la solicitud si cualquier entrada `v1` coincide.

Node.js (con `node:crypto`; Node entrega los nombres de encabezado en minúsculas):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

/**
 * rawBody: the request body as received, before JSON parsing.
 * headers: the request headers, with lowercase names.
 * secret: the webhook's signing secret, starting with whsec_.
 */
export function verifyProbaraWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - sentAt) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signatures.split(' ').some((entry) => {
    const [version, value = ''] = entry.split(',');
    if (version !== 'v1') return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Python (solo biblioteca estándar; pasa los nombres de encabezado en minúsculas):

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60

def verify_probara_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    """headers: the request headers with lowercase names. raw_body: the body as received."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - sent_at) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1":
            continue
        try:
            given = base64.b64decode(value, validate=True)
        except ValueError:
            continue
        if hmac.compare_digest(given, expected):
            return True
    return False
```

## Política de entregas

| Ajuste                         | Valor                                                                                                                                                                                                                                       |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tiempo máximo por intento      | 10 segundos, para los encabezados de la respuesta y el cuerpo                                                                                                                                                                               |
| Éxito                          | Cualquier estado `2xx`                                                                                                                                                                                                                      |
| Intentos por entrega           | 8 intentos: el primero y siete reintentos. Una prueba hace un solo intento.                                                                                                                                                                 |
| Redirecciones                  | No se siguen. Una redirección es un intento fallido. Antes de cada intento se resuelve el host, y una dirección fuera de internet pública hace fallar el intento con `The endpoint resolves to a non-public address`.                       |
| Desactivación automática       | Tras 5 entregas seguidas fallidas, sin ninguna entrega exitosa durante 72 horas (desde la creación, si nunca tuvo una). Probara registra `webhook.auto_disabled` y envía un correo a los propietarios y administradores de la organización. |
| Cuerpo de la solicitud         | El envoltorio estándar ocupa hasta 256 KiB. Un cuerpo personalizado de más de 256 KiB no se envía.                                                                                                                                          |
| Historial de entregas          | Las entregas terminadas se conservan 30 días. Eliminar un webhook elimina su historial.                                                                                                                                                     |
| Cuerpo de respuesta conservado | Hasta 4096 bytes por entrega                                                                                                                                                                                                                |
| Agente de usuario              | `Probara-Webhooks/1.0`                                                                                                                                                                                                                      |
| Webhooks por proyecto          | Hasta 20 webhooks                                                                                                                                                                                                                           |

Los reintentos siguen este calendario:

| Intento | Se envía                                   | Espera (segundos) |
| ------- | ------------------------------------------ | ----------------- |
| 1       | De forma asíncrona, después del evento     | 0                 |
| 2       | Al menos 30 segundos después del intento 1 | 30                |
| 3       | Al menos 2 minutos después del intento 2   | 120               |
| 4       | Al menos 10 minutos después del intento 3  | 600               |
| 5       | Al menos 30 minutos después del intento 4  | 1800              |
| 6       | Al menos 1 hora después del intento 5      | 3600              |
| 7       | Al menos 3 horas después del intento 6     | 10800             |
| 8       | Al menos 6 horas después del intento 7     | 21600             |

El último intento llega al menos 10 horas y 42 minutos después del primero.

## Responder

- Un estado `2xx` en 10 segundos cuenta como entregado. Solo el código de estado decide el éxito.
- Cualquier otro resultado es un intento fallido y se reintenta según el calendario anterior: un estado fuera de `2xx`, una redirección, un tiempo de espera agotado o un error de conexión.
- Probara puede enviar el mismo evento más de una vez; por ejemplo, cuando un reintento sigue a una respuesta que llegó después del tiempo de espera. Ignora un evento cuyo `webhook-id` ya hayas procesado.
- No dependas del orden de llegada, porque los reintentos pueden entregar un evento anterior después de uno posterior. Usa `createdAt` para ordenar los eventos.
