Webhooks
Recibe un aviso firmado en tu sistema cuando algo cambia, sin consultar la API a cada rato.
Un webhook le avisa a tu sistema cuando ocurre un evento en la organización. Se registra en la pestaña Webhooks de una integración, como explica la guía de Integraciones.
Eventos
Una integración solo recibe los eventos que puede consultar con sus permisos. Si el permiso está limitado a algunas razones sociales, recibe solo los eventos de esas razones sociales.
| Evento | Cuándo se envía | Permisos que necesita |
|---|---|---|
employee.created | Se crea un empleado | Ver empleados y ver sueldos |
employee.updated | Se actualiza un empleado | Ver empleados y ver sueldos |
employee.terminated | Se da de baja un empleado | Ver empleados y ver sueldos |
payroll_period.opened | Se abre una corrida | Ver nómina |
payroll_period.calculated | Se calcula una corrida | Ver nómina |
payroll_period.closed | Se cierra una corrida | Ver nómina |
receipt.stamped | Un recibo queda timbrado | Ver nómina |
receipt.failed | Un recibo queda con error de timbrado | Ver nómina |
time_off.approved | Se aprueba una solicitud de tiempo libre | Ver incidencias |
Un evento de empleado llega si todos sus permisos alcanzan una misma razón social del empleado; los demás, solo si alcanza todas las de la corrida, el recibo o la solicitud.
El aviso de un periodo cerrado sale segundos después del cierre; el de un finiquito y los demás eventos, en menos de un minuto.
Qué recibes
Cada aviso es un POST con un cuerpo JSON. Solo lleva identificadores: consulta el resto en la API con tus permisos.
{
"id": "f1bebfe5-9c1e-4eff-b228-8bc27b925b06",
"type": "payroll_period.closed",
"createdAt": "2026-11-03T19:26:33.002Z",
"data": {
"payrollRunId": "c1db0f37-a1c6-4ed8-b4fc-b85663f1c2ed",
"payrollRunNumber": 1,
"payrollType": "ordinary",
"payrollCalendarNumber": 1,
"periodNumber": "2026-21"
}
}data cambia según el evento:
| Eventos | Campos de data |
|---|---|
employee.* | employeeId, employeeNumber |
payroll_period.* | payrollRunId, payrollRunNumber, payrollType, payrollCalendarNumber, periodNumber. Los dos últimos son null si la corrida no tiene calendario o periodo. |
receipt.* | receiptId, uuid, payrollRunNumber, employeeNumber. uuid es null si el recibo no se timbró. |
time_off.approved | timeOffRequestId, timeOffType, employeeNumber, startDate, endDate |
Y estas cabeceras:
| Cabecera | Qué contiene |
|---|---|
X-Webhook-Id | El id del evento. Es el mismo en cada reintento, así que utilízalo para no procesar dos veces un aviso. |
X-Webhook-Event | El tipo de evento |
X-Webhook-Timestamp | Segundos Unix del momento en que se envió este intento |
X-Webhook-Signature | sha256= seguido de la firma en hexadecimal |
Verifica la firma
La firma es un HMAC-SHA256, con el Secreto de firma del webhook, de <X-Webhook-Timestamp>.<cuerpo>. Calcúlala sobre el cuerpo tal como llegó, antes de interpretar el JSON, y rechaza los avisos con una marca de tiempo vieja para evitar que alguien repita uno.
import { createHmac, timingSafeEqual } from "node:crypto"
export function isValidWebhook(rawBody: string, timestamp: string, signature: string, secret: string) {
const isRecent = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
const expected = `sha256=${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`
return (
isRecent &&
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
)
}Responde y reintentos
Responde con un código 2xx en menos de 10 segundos. Cualquier otra respuesta cuenta como intento fallido, también una redirección, que no se sigue.
Un intento fallido se reintenta a 1 minuto, 5 minutos, 30 minutos, 2 horas y 6 horas. Después del sexto intento, la entrega queda en Fallido y no se vuelve a enviar. Si la integración pierde el permiso o el webhook deja de recibir ese evento antes de un reintento, la entrega queda en Fallido sin enviarse. Si el webhook se pausa, sus avisos pendientes esperan y llegan cuando se reactiva, así que pueden llegar tiempo después del evento. Los avisos pueden llegar en otro orden del que ocurrieron.
La URL debe utilizar https, no puede incluir usuario ni contraseña y debe apuntar a una dirección pública: se rechaza si resuelve a una dirección interna. Cada intento queda en la pestaña Logs de la integración, con el código que contestó tu sistema o Sin respuesta.

