Ir al contenido

Webhooks salientes

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. Para gestionar webhooks desde código, lee la API de webhooks.

  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.

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.

EventoSe envía cuando
test_case.createdSe crea un caso de prueba o se clona uno a uno.
test_case.updatedSe edita o mueve un caso de prueba.
test_case.archivedSe archiva un caso de prueba.
test_case.restoredSe restaura un caso de prueba archivado.
test_case.deletedSe elimina un caso de prueba.
test_case.commentedSe añade un comentario a un caso de prueba.
suite.createdSe crea o clona una suite.
suite.updatedSe edita o mueve una suite.
suite.deletedSe elimina una suite.
test_plan.createdSe crea un plan de prueba.
test_plan.updatedSe edita un plan de prueba.
test_plan.status_changedCambia el estado de un plan de prueba.
test_plan.deletedSe elimina un plan de prueba.
milestone.createdSe crea un hito.
milestone.updatedSe edita un hito.
milestone.status_changedCambia el estado de un hito.
milestone.deletedSe elimina un hito.
test_run.createdSe crea o clona una ejecución.
test_run.updatedSe edita una ejecución.
test_run.closedSe cierra una ejecución.
test_run.abortedSe aborta una ejecución.
test_run.deletedSe elimina una ejecución.
test_run.results_submittedSe 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.createdSe marca un único caso en una ejecución.
defect.createdSe crea un defecto.
defect.updatedSe edita un defecto.
defect.status_changedCambia el estado de un defecto, incluido cuando se reabre.
defect.commentedSe añade un comentario a un defecto.
defect.deletedSe elimina un defecto.
shared_step.createdSe crea un paso compartido.
shared_step.updatedSe edita un paso compartido.
shared_step.deletedSe 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.

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.

CampoTipoSignificado
idULIDIdentificador del evento. Se envía como webhook-id.
typecadenaNombre del evento, del catálogo.
version1Versión del envoltorio. Solo cambia ante un cambio incompatible en el propio envoltorio.
createdAtcadenaMomento en que se creó el evento, en ISO 8601 UTC.
testbooleanotrue para una entrega enviada con Enviar prueba.
organizationobjetoulid y name de la organización.
projectobjetoulid, key (el código del proyecto) y name del proyecto.
actorobjetotype (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.
entityobjetotype (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.
dataobjeto o nullLa 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.
changesobjeto o nullEn 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:

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

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

EncabezadoValor
Content-Typeapplication/json para la carga útil estándar. En otro caso, el tipo de contenido de tu plantilla.
User-AgentProbara-Webhooks/1.0
webhook-idEl id del evento. Es el mismo en cada intento y en cada reenvío de ese evento.
webhook-timestampTiempo Unix en segundos, en el momento en que se firmó este intento.
webhook-signaturev1, seguido de la firma HMAC-SHA256 codificada en base64.

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):

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):

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
AjusteValor
Tiempo máximo por intento10 segundos, para los encabezados de la respuesta y el cuerpo
ÉxitoCualquier estado 2xx
Intentos por entrega8 intentos: el primero y siete reintentos. Una prueba hace un solo intento.
RedireccionesNo 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áticaTras 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 solicitudEl envoltorio estándar ocupa hasta 256 KiB. Un cuerpo personalizado de más de 256 KiB no se envía.
Historial de entregasLas entregas terminadas se conservan 30 días. Eliminar un webhook elimina su historial.
Cuerpo de respuesta conservadoHasta 4096 bytes por entrega
Agente de usuarioProbara-Webhooks/1.0
Webhooks por proyectoHasta 20 webhooks

Los reintentos siguen este calendario:

IntentoSe envíaEspera (segundos)
1De forma asíncrona, después del evento0
2Al menos 30 segundos después del intento 130
3Al menos 2 minutos después del intento 2120
4Al menos 10 minutos después del intento 3600
5Al menos 30 minutos después del intento 41800
6Al menos 1 hora después del intento 53600
7Al menos 3 horas después del intento 610800
8Al menos 6 horas después del intento 721600

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

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