# Webhooks (/docs/developers/webhooks)

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](/docs/configuration/access#integraciones).

## Eventos [#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 [#que-recibes]

Cada aviso es un `POST` con un cuerpo JSON. Solo lleva identificadores: consulta el resto en la API con tus permisos.

```json
{
  "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 [#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.

```ts
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 [#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**.