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.
Ruta rápida
Sección titulada «Ruta rápida»- Expón un endpoint
https://que acepte el método configurado (POSTpor defecto). - Verifica
webhook-signaturecontra el cuerpo sin procesar de la solicitud, con tu secreto. Los pasos están en Verificar la firma, más abajo. - Responde con un estado
2xxen 10 segundos y, después, realiza el trabajo lento. - Omite los eventos que ya procesaste, usando
webhook-id. Probara puede enviar el mismo evento más de una vez.
Catálogo de eventos
Sección titulada «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.archivedytest_case.restoredpor los casos que archiva y restaura. - Los pasos compartidos que pertenecen a la organización y no a un proyecto.
Envoltorio del evento
Sección titulada «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:
sourceesreporten un informe de CI, ynullen las demás acciones masivas.resultses el número de resultados que registró un informe de CI, ynullen las demás acciones masivas.casescontiene los detalles de la acción masiva:summary, con los recuentosaffectedyunchanged, ycases, con los casos listados.omittedCountaparece 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" } }}Solicitud
Sección titulada «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
Sección titulada «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,.
- Lee el cuerpo sin procesar antes de analizarlo. Volver a serializar el JSON ya analizado produce otros bytes.
- Rechaza la solicitud si falta alguno de los tres encabezados
webhook-*. - Rechaza las marcas de tiempo fuera de tu tolerancia. Los ejemplos aceptan cinco minutos.
- Calcula la firma esperada y compárala en tiempo constante con cada entrada
v1dewebhook-signature. El encabezado es una lista separada por espacios, así que acepta la solicitud si cualquier entradav1coincide.
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 base64import hashlibimport hmacimport 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 FalsePolítica de entregas
Sección titulada «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
Sección titulada «Responder»- Un estado
2xxen 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-idya hayas procesado. - No dependas del orden de llegada, porque los reintentos pueden entregar un evento anterior después de uno posterior. Usa
createdAtpara ordenar los eventos.