Webhooks
Te avisamos en cuanto pasa algo, firmado y con reintentos. Así no tienes que preguntar cada minuto por si acaso.
Eventos
| Evento | Cuándo se dispara |
|---|---|
| reservation.created | Una reserva queda confirmada. |
| reservation.updated | Cambia el estado de una reserva (entrada, salida). |
| reservation.cancelled | Una reserva se cancela. |
| checkin.completed | El huésped completa el check-in y sus viajeros. |
| payment.received | Se registra un cobro de la reserva. |
| access.opened | Se abre una puerta o barrera, por el medio que sea. |
| access.denied | Una matrícula llega a la barrera y no está autorizada. |
| access.exit_unknown | Sale un vehículo cuya matrícula no reconocemos: la barrera se abre igual (ya está dentro) y queda anotado. |
| traveler.registered | Se registra un viajero en el parte (escaneado o tecleado). |
| invoice.issued | Se emite una factura. |
access.denied es más útil de lo que parece
Una matrícula que llega a la barrera y no está autorizada es justo lo que quiere ver quien vigila un recinto desatendido: alguien intentando entrar sin reserva, o una reserva que no cuadra con el vehículo.Qué recibes
POST https://tu-servidor.com/pernocta/webhook
Content-Type: application/json
User-Agent: Pernocta-Webhooks/1
X-Pernocta-Event: access.opened
X-Pernocta-Delivery: 0f4c9a1e-…
X-Pernocta-Signature: t=1755792000,v1=8a1f…
{
"id": "0f4c9a1e-…",
"event": "access.opened",
"createdAt": "2026-08-21T18:00:00.000Z",
"campingId": "bebdfebc-…",
"data": {
"via": "anpr",
"plate": "1234ABC",
"reservationId": "…",
"lockLabel": "Barrera de entrada",
"at": "2026-08-21T18:00:00.000Z"
}
}Verificar la firma
HMAC-SHA256 de `${timestamp}.${cuerpo_crudo}` con el secreto del endpoint, que se muestra una sola vez al crearlo. El timestamp va dentro de lo firmado: por eso una petición capturada hoy no se puede reenviar mañana.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verificar(secreto, cuerpoCrudo, cabecera) {
const partes = Object.fromEntries(cabecera.split(',').map((p) => p.trim().split('=')))
if (!partes.t) return false
// Rechaza lo que tenga más de 5 minutos: es lo que corta el reenvío de una petición capturada.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(partes.t)) > 300) return false
const esperado = createHmac('sha256', secreto)
.update(`${partes.t}.${cuerpoCrudo}`)
.digest('hex')
const a = Buffer.from(esperado, 'hex')
const b = Buffer.from(partes.v1 ?? '', 'hex')
// Comparación en tiempo constante: un === filtra por dónde deja de coincidir.
return a.length === b.length && timingSafeEqual(a, b)
}function verificar(string $secreto, string $cuerpoCrudo, string $cabecera): bool {
parse_str(str_replace(',', '&', $cabecera), $partes);
if (empty($partes['t']) || abs(time() - (int) $partes['t']) > 300) return false;
$esperado = hash_hmac('sha256', $partes['t'] . '.' . $cuerpoCrudo, $secreto);
return hash_equals($esperado, $partes['v1'] ?? '');
}import hmac, hashlib, time
def verificar(secreto: str, cuerpo_crudo: bytes, cabecera: str) -> bool:
partes = dict(p.strip().split("=", 1) for p in cabecera.split(","))
if abs(time.time() - int(partes["t"])) > 300:
return False
esperado = hmac.new(
secreto.encode(), f'{partes["t"]}.'.encode() + cuerpo_crudo, hashlib.sha256
).hexdigest()
return hmac.compare_digest(esperado, partes.get("v1", ""))Usa el cuerpo crudo
Si parseas el JSON y lo vuelves a serializar, la firma no cuadrará: un espacio de más ya cambia el hash. En Express,express.raw(); en Next, await req.text().Reintentos
Responde 2xx cuanto antes. Si respondes otra cosa, o tardas más de 8 segundos, reintentamos a 1, 5, 15, 60 y 360 minutos desde el fallo. Después damos la entrega por perdida y lo verás marcado en el panel.
- Acepta primero, procesa después: encola y contesta. Un 200 rápido vale más que uno completo y lento.
- Puedes recibir el mismo evento dos veces (una entrega que llegó pero cuya respuesta se perdió). Descarta por
id, que es único. - El orden no está garantizado: no supongas que
checkin.completedllega después dereservation.created.
Probarlo
En Ajustes › API e integraciones, el botón Enviar prueba lanza un evento con datos sintéticos a tus endpoints. Debajo de cada uno se ve el historial de envíos con el código de respuesta que devolvió tu servidor, que suele ser suficiente para saber qué falla sin mirar tus logs.
Tu endpoint tiene que ser público
Comprobamos el destino antes de cada envío y no salimos hacia direcciones internas (localhost, redes privadas, metadatos del proveedor cloud). Para desarrollo local usa un túnel.