Webhooks

Te avisamos en cuanto pasa algo, firmado y con reintentos. Así no tienes que preguntar cada minuto por si acaso.

Eventos

EventoCuándo se dispara
reservation.createdUna reserva queda confirmada.
reservation.updatedCambia el estado de una reserva (entrada, salida).
reservation.cancelledUna reserva se cancela.
checkin.completedEl huésped completa el check-in y sus viajeros.
payment.receivedSe registra un cobro de la reserva.
access.openedSe abre una puerta o barrera, por el medio que sea.
access.deniedUna matrícula llega a la barrera y no está autorizada.
access.exit_unknownSale un vehículo cuya matrícula no reconocemos: la barrera se abre igual (ya está dentro) y queda anotado.
traveler.registeredSe registra un viajero en el parte (escaneado o tecleado).
invoice.issuedSe 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 a tu endpointhttp
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.

Node.jsjavascript
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)
}
PHPphp
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'] ?? '');
}
Pythonpython
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.completed llega después de reservation.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.