Recibir eventos firmados
Suscribirse a los eventos del workspace y verificar cada entrega.
POST /webhooks suscribe tu endpoint a los eventos del workspace. Le dices a Vitrina a qué eventos escuchar y a qué URL los mandamos. Cuando algo pasa (se crea un contacto, se agenda una cita, cambia de etapa un lead) sale un POST firmado a tu endpoint.
Las respuestas de esta página salen de una corrida contra un receptor de prueba. Los identificadores serán otros; la forma es la misma.
Dos modos: el aviso o los datos
Cada entrega trae el aviso: qué recurso cambió, qué cambió, quién lo hizo y cuándo. Son resource (con su id y una url para leerlo), changes cuando aplica, author y created_at. Con eso tu integración lee el recurso con su propia credencial. Esa lectura respeta sus permisos y su visibilidad, y queda en el registro de accesos. Es el modo por defecto.
Una suscripción puede pedir además los datos del recurso («Incluir datos del recurso», include_data: true). Entonces la entrega trae data, el recurso tal como estaba cuando pasó el evento. Llega solo si el dueño de la suscripción puede leerlo en ese momento. Si no, llega el aviso con data_omitted diciendo por qué. Nunca se omite en silencio: toda entrega trae exactamente uno de los dos, data o data_omitted.
Una suscripción no tiene permisos propios, y por eso tiene dueño. El dueño es quien creó la suscripción, un miembro o una API key, o quien cambió por última vez su url, sus events o include_data. Se evalúa en cada entrega, no cuando se creó.
Estas son todas las razones por las que data puede no venir:
data_omitted | Qué significa |
|---|---|
not_requested | La suscripción pidió solo el aviso. |
missing_scope:<scope> | El dueño no tiene el permiso de lectura de ese recurso (por ejemplo missing_scope:contacts:read). El permiso de cada evento está en el catálogo. |
restricted_visibility | El dueño ve solo algunos registros: los asignados a él, o los de algunas sucursales. |
owner_unavailable | El dueño ya no es miembro activo, o su API key fue revocada o venció. Vuelve a crear la suscripción, o edita su url o sus events, y pasas a ser tú el dueño. |
sensitive | El evento es sobre un dato sensible (datos de salud). Llega siempre como aviso, pida lo que pida la suscripción. |
Así se ven las tres entregas de un mismo evento: tres suscripciones a contact.created, y una sola creación de contacto hecha por un miembro desde la app. La primera pidió solo el aviso:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data_omitted": "not_requested"
}La segunda pidió los datos, pero la creó una API key que solo tiene webhooks:read, webhooks:write y leads:read:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data_omitted": "missing_scope:contacts:read"
}La tercera pidió los datos y su dueño tiene contacts:read sin restricciones de visibilidad:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data": {
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"name": "María González",
"email": "[email protected]",
"phone": "+56912345678",
"lifecycle_stage": "unknown",
"origin_channel": null,
"company_id": null,
"created_at": "2026-09-22T00:16:24.356+00:00"
}
}Trampa
Editar la URL te hace dueño
Cambiar la url, los events o include_data con PUT /webhooks/{id} deja
como dueño a quien hace la llamada. Si esa credencial no tiene contacts:read,
los eventos siguientes llegan con "data_omitted": "missing_scope:contacts:read",
aunque antes llegaran con datos. Cambiar enabled o description no toca al
dueño.
Suscribirse
Una llamada. Pide webhooks:write. Quien la hace queda como dueño.
curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tu-dominio.cl/vitrina",
"events": ["contact.created"],
"description": "sincronización de contactos",
"include_data": true
}'{
"data": {
"id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"url": "https://tu-dominio.cl/vitrina",
"secret": "whsec_cb6…",
"events": ["contact.created"],
"enabled": true,
"description": "sincronización de contactos",
"created_at": "2026-09-22T00:16:13.559911+00:00",
"updated_at": "2026-09-22T00:16:13.559911+00:00",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "90aa1346-3fdc-44a0-9ac1-2de84fe76689",
"include_data": true,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}secret llega entero solo en esta respuesta. Arriba va truncado; el tuyo viene completo, con whsec_ y 64 caracteres hexadecimales. Guárdalo donde guardas tus credenciales: con él verificas que un POST que dice venir de Vitrina viene de Vitrina. Toda lectura posterior (GET /webhooks, GET /webhooks/{id}) devuelve los primeros nueve caracteres y nada más. Ningún endpoint lo vuelve a mostrar. Si lo pierdes, borra la suscripción y crea otra.
include_data es false si no lo mandas: la suscripción recibe el aviso. owner_kind y owner_id dicen quién es el dueño: member con el id del usuario, o api_key con el id de la key. Un token personal cuenta como el miembro al que representa, con los permisos que tenga el token y ese miembro a la vez.
events acepta los nombres del catálogo, hasta veinte por suscripción, y también "*", que es "todos, incluidos los que agreguemos después". * es cómodo para explorar y caro en producción: vas a recibir eventos de toda la plataforma, y de tu vertical, que tu endpoint tendrá que descartar uno por uno. Si necesitas más de veinte nombres, crea una segunda suscripción.
Trampa
No podemos entregar a una dirección privada
Con http://localhost:4000/webhooks/vitrina, la URL que uno tiene a mano
mientras desarrolla, la suscripción no se crea:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Host localhost resolves to a private/blocked address (::1)",
"requestId": "25de7ca1-b24e-4e6c-9ffb-bd2d696eeb44"
}
}La URL se resuelve por DNS al registrarla, y toda dirección privada, de loopback, link-local o de metadatos de nube queda fuera.
Para desarrollar, expón tu receptor con un túnel y registra la URL pública.
Un nombre de evento que no está en el catálogo se rechaza en la validación del body, y el mensaje trae la lista completa de los que sí están:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"body": [{
"path": "events.0",
"message": "Invalid enum value. Expected '*' | 'ai_agent.publish' | 'ai_agent_graph.publish' | …",
"code": "invalid_enum_value"
}]
},
"requestId": "8a2f3d17-6b04-4e51-9c3a-1d7e0f2b5a64"
}
}Lo que llega
Cada entrega es un POST con cinco headers propios y un body JSON:
X-Webhook-Event: contact.created
X-Webhook-Event-Id: ec8ca90e-964f-45d3-adee-977e42f13a81
X-Webhook-Timestamp: 1790036189
X-Webhook-Signature: t=1790036189,v1=…
Vitrina-Livemode: trueEl sobre es igual para todos los eventos:
ides el identificador del evento, no de la entrega: los reintentos y los reenvíos repiten el mismo. Es la llave con la que tu endpoint se hace idempotente.typees el nombre del evento, yversionla versión de su esquema. Solo sube cuando cambiamosdatade una forma que rompe a quien lo lee; un campo nuevo no la sube. Si tu código depende de la forma dedata, comparaversionantes de leerlo.livemodeesfalsecuando el evento salió de un workspace sandbox (modo de prueba) ytrueen cualquier otro caso. Va también en el headerVitrina-Livemode, y como está en el body firmado, verificar la firma verifica también este valor. Más en Modo de prueba.resourcedice sobre qué es:type,idyurl. Laurles unGETde esta misma API; pídelo con tu credencial. Esnullcuando ningúnGETdevuelve ese recurso por sí solo.changes, cuando está, trae{ "from": …, "to": … }por cada identificador o estado que cambió, por ejemplo la etapa de un lead enlead.stage_changed. Nunca trae un dato personal.authores quien lo hizo:member,api_key,ai_agent,systemocontact, con suidy su nombre. Si un miembro actuó a través de un token personal o de una aplicación conectada, la trae envia.dataodata_omitted, según los dos modos.
Lo que trae data en cada evento está en el catálogo, junto con su versión, su recurso y el permiso que hace falta para recibirlo. Se genera desde el mismo GET /webhooks/events que puedes llamar tú.
Todo evento se guarda en la misma transacción que el cambio que describe, y se entrega después. Si nuestro proceso se cae entre una cosa y la otra, el evento no se pierde: se entrega cuando vuelve. Tu endpoint puede recibirlo unos segundos después del cambio.
Modo de prueba
Un workspace sandbox suscribe, filtra events, pide include_data y recibe reintentos y pausa automática igual que cualquier otro: la única diferencia es livemode. Cada entrega que sale de un sandbox trae "livemode": false en el body y el header Vitrina-Livemode: false; una entrega de un workspace real trae true en los dos.
Los dos workspaces nunca se cruzan: una suscripción creada en el sandbox solo recibe eventos de su propio sandbox, y una suscripción del workspace real solo recibe los suyos. Si un mismo endpoint atiende suscripciones de los dos, revisa livemode (o el header) antes de procesar, para no mezclar datos de prueba con datos reales.
Verificar la firma
X-Webhook-Signature viene como t=<segundos unix>,v1=<hex>, donde el hex es un HMAC-SHA256 sobre el string ${t}.${body} con el secreto de tu suscripción.
Dos reglas:
Firma los bytes que recibiste. JSON.parse y JSON.stringify te devuelven un objeto equivalente y un string distinto, y la firma es sobre el string. En Express eso significa express.raw({ type: 'application/json' }) en la ruta del webhook, o quedarte con el buffer crudo:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));Compara en tiempo constante y mira el reloj. Una comparación con === filtra información por el tiempo que tarda en fallar. Una firma sin ventana de tiempo no caduca: quien haya visto una entrega puede reenviarla cuando quiera. La ventana son 300 segundos.
El v1 de arriba va recortado porque es la firma de una entrega real. El ejemplo resuelto de Empezar trae los tres valores completos (body, timestamp y secreto) y el hex que producen, con el verificador que lo calcula.
Reintentos
Con un receptor que responde 500, esto es lo que queda en el registro de una entrega:
[
{ "attempt": 2, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:03.819073+00:00" },
{ "attempt": 3, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:17.461594+00:00" },
{ "attempt": 4, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:39.27822+00:00" },
{ "attempt": 5, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:20:22.437628+00:00" }
]Cada intento espera el doble que el anterior: cinco segundos, diez, veinte, cuarenta. La escalera completa y el tope de intentos están en el catálogo.
Todos los intentos llevan el mismo X-Webhook-Event-Id. Por eso el endpoint tiene que ser idempotente. Un 200 tuyo que llega tarde, un timeout de red o un deploy tuyo en mal momento producen exactamente esto. Sin dedup por id vas a procesar el mismo contacto varias veces.
Cortamos la espera a los diez segundos. Contesta 2xx apenas tengas el body guardado, y procesa después. Si tu trabajo tarda más que eso, la entrega cuenta como fallida aunque la hayas hecho bien.
Pausa automática
Un endpoint caído no se reintenta para siempre. La suscripción se pausa sola cuando 20 entregas seguidas agotan sus reintentos, o cuando lleva 24 horas fallando sin una sola respuesta correcta. Vale lo que pase primero. Son veinte entregas, no veinte intentos: un deploy tuyo de diez segundos en medio de una ráfaga no la pausa. A los dueños y administradores del workspace les llega un aviso, en las notificaciones de la app y por correo.
Una suscripción pausada por el primer umbral queda así:
{
"paused_at": "2026-09-22T00:20:22.623471+00:00",
"paused_reason": "consecutive_failures",
"consecutive_failures": 5,
"consecutive_failed_deliveries": 20,
"failing_since": "2026-09-22T00:18:58.437362+00:00"
}paused_reason es consecutive_failures o failing_24h. consecutive_failures cuenta intentos y consecutive_failed_deliveries cuenta entregas; las dos vuelven a cero con la primera respuesta buena.
Mientras está pausada no recibe nada, y los eventos de mientras no se guardan para después: cuando la reanudes, ponte al día leyendo los recursos o reenviando desde el registro de entregas. Pausada no es lo mismo que apagada: enabled es tu interruptor; la pausa es nuestra.
Para reanudarla, arregla el endpoint y llama a:
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/resume \
-H "Authorization: Bearer $VITRINA_KEY"Responde 200 con la suscripción: paused_at en null y los dos contadores en cero, para que no vuelva a pausarse con la primera falla. Si no estaba pausada, la devuelve igual, sin cambios. En la app es el botón «Reanudar».
El registro de entregas
Cada intento deja una fila, el bueno y los malos. Es lo primero que hay que mirar cuando «no me llegan los webhooks».
curl https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/deliveries \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"id": 83,
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"attempt": 1,
"status_code": 200,
"error": null,
"response_excerpt": "{\"received\":true}",
"latency_ms": 997,
"created_at": "2026-09-22T00:20:54.77778+00:00",
"redelivery_of": 82
},
{
"id": 82,
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"attempt": 5,
"status_code": 500,
"error": "http_500",
"response_excerpt": "{\"error\":\"service unavailable\"}",
"latency_ms": 1034,
"created_at": "2026-09-22T00:20:22.437628+00:00",
"redelivery_of": null
}
],
"meta": { "pagination": { "limit": 50 }, "offset": 0 }
}Vienen las más nuevas primero. response_excerpt son los primeros 500 caracteres de lo que contestaste, suficiente para reconocer tu propio mensaje de error. error distingue las tres formas de fallar: http_<código> cuando respondiste algo que no es 2xx, timeout cuando no respondiste a tiempo, y el mensaje de red cuando no llegamos a conectarnos. Cada fila trae también request_payload, el body exacto que mandamos; va fuera del ejemplo de arriba para que se lea.
Reenviar una entrega
La fila 83 de arriba es un reenvío de la 82: el mismo evento, mandado de nuevo a mano.
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/deliveries/82/redeliver \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": {
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"delivery_id": 82,
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"status": "queued"
}
}Responde 202: el reenvío queda en cola y su resultado aparece en el registro como una fila nueva con redelivery_of apuntando a la original. Sale con el mismo X-Webhook-Event-Id, una firma nueva y un solo intento, a la url que tenga la suscripción ahora. Para tu endpoint es el mismo evento. Queda registrado en la auditoría del workspace.
Dos detalles. data se decide otra vez, con el dueño tal como está ahora: si perdió el permiso desde la primera entrega, el reenvío llega como aviso. Y funciona con la suscripción pausada, así que puedes probar el arreglo antes de reanudar, pero no con una apagada (409). En la app es el botón «Reenviar» de cada fila.
Apagar y borrar
PUT /webhooks/{id} con { "enabled": false } deja la suscripción en su lugar y detiene las entregas. Las que ya estaban en cola se descartan; no se acumulan para cuando la vuelvas a activar. Es lo que quieres durante un mantenimiento. El mismo PUT cambia url, events, description o include_data, y los tres que deciden adónde y qué se entrega te hacen dueño.
DELETE /webhooks/{id} la borra y devuelve 204. El secreto se va con ella. No se puede recuperar ni rotar en su lugar, así que rotar el secreto es borrar y volver a crear.