Ir al contenido
StayParity

Webhooks

Reciba eventos JSON firmados cuando una OTA ofrece un precio inferior al directo. Configuración, formato, firmas, reintentos y pruebas del receptor.

StayParity envía nuevas discrepancias a su servidor mediante JSON firmado, según los criterios de aviso del hotel. La firma sigue Standard Webhooks; el contrato también está en OpenAPI 3.1.

Configurar un receptor

  1. En Settings → Webhooks, seleccione Create key. Guarde la clave whsec_… en el servidor antes de cerrar: se muestra una vez y firma los envíos de todos los hoteles de la cuenta.
  2. En Settings → Alerts, abra la regla del hotel e indique Webhook URL. Una URL vacía desactiva sus webhooks.
  3. Verifique la firma, almacene el evento y responda rápidamente con 2xx.

Solo los administradores pueden crear claves o modificar la URL. Debe ser https://, pública y de hasta 2048 caracteres. Se rechazan direcciones IP, localhost, hosts de una sola etiqueta, nombres acabados en .local, .internal, .lan, .localhost o .home.arpa, y URLs con usuario o contraseña. Si necesita un token propio, inclúyalo en la ruta o consulta.

Nunca enviamos eventos sin firma. Sin clave, los webhooks no se envían y la aplicación lo indica junto a la URL.

Eventos

El único evento actual es disparity.opened. Ignore eventos desconocidos para permitir futuras ampliaciones.

disparity.opened

Se genera una vez cuando se abre una discrepancia que cumple la regla del hotel. Deben cumplirse ambos umbrales: importe y porcentaje. También se aplican los canales elegidos y Confirmed only o Also seen once. La primera opción requiere confirmación en la página del canal; la segunda permite observaciones de Google Hotels sin esa confirmación.

No se repite mientras la discrepancia sigue abierta. Si se cierra y vuelve a abrirse, tendrá otro disparityId y otro evento. Se envía al terminar la revisión; si la discrepancia se corrige o descarta antes del envío, no se envía.

Cabeceras

Cada envío es POST, con Content-Type: application/json.

CabeceraValor
webhook-idIdentificador del envío, igual al id del cuerpo. Se mantiene en los reintentos.
webhook-timestampSegundos Unix de la firma de este intento. Se renueva en cada intento.
webhook-signaturev1, seguido de la firma HMAC-SHA256 en base64. Se renueva en cada intento.

Cuerpo

CampoTipoSignificado
version1Versión del formato. Ignore campos desconocidos.
idstringIdentificador del envío para evitar duplicados.
event"disparity.opened"Evento ocurrido.
disparityIdstringIdentificador de la discrepancia.
hotel.idstringIdentificador del hotel.
hotel.namestringNombre configurado en la aplicación.
otastringCanal en minúsculas: booking, expedia o proveedor de Google Hotels, como trip o agoda.
stayDatestringFecha de estancia YYYY-MM-DD.
occupancyintegerHuéspedes, entre 1 y 10.
directPriceEurMinorintegerPrecio directo en céntimos de euro.
otaPriceEurMinorintegerPrecio del canal en céntimos de euro.
deltaEurMinorintegerDiferencia de precio en céntimos.
deltaPctnumberDiferencia como porcentaje del precio directo, redondeada a dos decimales.
confidence"sweep" o "confirmed"Observación de Google Hotels o comprobación en la página del canal.
detectedAtstringPrimera detección en UTC, ISO 8601.

Los importes son enteros en céntimos de euro, aunque el hotel use otra moneda: 17900 representa 179,00 €. Compare los enteros; no convierta a coma flotante para compararlos.

Ejemplo

disparity.opened
{
  "version": 1,
  "id": "mh7d0xq4c2v9n8b1k6t3r5w0z2y4p8s6",
  "event": "disparity.opened",
  "disparityId": "jn7cq2b1x9d8w4fzr3m6kt5v0h7s2e8a",
  "hotel": { "id": "kd72mbx0vy4p9s1c6tqh8wf3gr5n2j7e", "name": "Hotel Aurora" },
  "ota": "booking",
  "stayDate": "2026-10-16",
  "occupancy": 2,
  "directPriceEurMinor": 18900,
  "otaPriceEurMinor": 17900,
  "deltaEurMinor": 1000,
  "deltaPct": 5.29,
  "confidence": "confirmed",
  "detectedAt": "2026-10-09T12:02:00.000Z"
}

Verificar la firma

Verifique cada envío antes de utilizarlo. La firma autentica el cuerpo con su clave. La marca de tiempo limita la repetición de mensajes antiguos; no sustituye al control de duplicados.

Utilice la biblioteca oficial Standard Webhooks. Compara en tiempo constante y rechaza marcas de tiempo a más de cinco minutos de su reloj. Pase la clave completa, incluido whsec_, y el cuerpo original antes de analizar el JSON. Volver a serializarlo cambia los bytes y puede invalidar la firma.

Node

npm install express standardwebhooks
server.js
import express from "express";
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.STAYPARITY_WEBHOOK_SECRET);
const app = express();

// express.raw keeps the body as bytes, as it arrived.
app.post("/stayparity", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body.toString("utf8"), req.headers);
  } catch {
    return res.status(401).end();
  }
  // Store it and answer now; do the slow work afterwards.
  res.status(204).end();
});

Python

pip install flask standardwebhooks
app.py
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["STAYPARITY_WEBHOOK_SECRET"])

@app.post("/stayparity")
def stayparity():
    try:
        event = webhook.verify(request.get_data(as_text=True), dict(request.headers))
    except WebhookVerificationError:
        return "", 401
    # Store it and answer now; do the slow work afterwards.
    return "", 204

PHP

composer require standard-webhooks/standard-webhooks
stayparity.php
<?php
require __DIR__ . '/vendor/autoload.php';

$webhook = new \StandardWebhooks\Webhook(getenv('STAYPARITY_WEBHOOK_SECRET'));
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);

try {
    $event = $webhook->verify($body, $headers);
} catch (\Throwable $e) {
    http_response_code(401);
    exit;
}
// Store it and answer now; do the slow work afterwards.
http_response_code(204);

Los ejemplos verifican la firma y responden. Añada almacenamiento duradero y control de duplicados antes de confirmar la recepción en producción.

Sin biblioteca

  1. Quite whsec_ y decodifique el resto desde base64: son los bytes de la clave HMAC.
  2. Una webhook-id, webhook-timestamp y cuerpo original con puntos: {id}.{timestamp}.{body}.
  3. Calcule HMAC-SHA256 y codifique el resultado en base64.
  4. La cabecera contiene entradas v1,<firma> separadas por espacios. Acepte si alguna coincide mediante comparación en tiempo constante. Actualmente enviamos una entrada.
  5. Rechace marcas de tiempo a más de cinco minutos de su reloj, en cualquier dirección.

Este ejemplo TypeScript usa Web Crypto y funciona en Node 20+, Bun, Deno y Cloudflare Workers:

verify-webhook.ts
// Verifies a StayParity delivery without a library. Web Crypto only, so it
// runs as is in Node 20+, Bun, Deno and Cloudflare Workers.

/** How old (or how far ahead) a delivery may be: five minutes. */
const TOLERANCE_SECONDS = 5 * 60;

/**
 * Returns the parsed event, or throws. `secret` is the whole key, `whsec_`
 * included; `rawBody` is the request body exactly as it arrived.
 */
export async function verifyWebhook(
  secret: string,
  headers: Headers,
  rawBody: string,
): Promise<unknown> {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (id === null || timestamp === null || signatures === null) {
    throw new Error("Missing webhook headers");
  }

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) {
    throw new Error("Timestamp outside the tolerance");
  }

  const key = await crypto.subtle.importKey(
    "raw",
    base64ToBytes(secret.replace(/^whsec_/, "")),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${rawBody}`);

  // A space-separated list of `v1,<base64>`. Any one valid entry will do.
  for (const entry of signatures.split(" ")) {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || signature === undefined) continue;
    let bytes: Uint8Array<ArrayBuffer>;
    try {
      bytes = base64ToBytes(signature);
    } catch {
      // A malformed entry does not invalidate another, valid signature.
      continue;
    }
    const valid = await crypto.subtle.verify("HMAC", key, bytes, signed);
    if (valid) return JSON.parse(rawBody);
  }
  throw new Error("No valid signature");
}

function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
  return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}

Reintentos

Responda en diez segundos:

RespuestaResultado
2xxEntregado. Basta 200 o 204 vacío; no leemos el cuerpo.
5xx, timeout o error de redSe reintenta hasta un máximo de 5 intentos.
3xxFallo sin reintento. No seguimos redirecciones.
4xxRechazo sin reintento. Úselo solo cuando repetir no pueda resolver el problema.

Los intervalos aproximados son 10, 20, 40 y 80 segundos, con una variación de hasta la mitad en cada dirección. Cada intento conserva webhook-id y lleva nueva marca de tiempo y firma. El orden de llegada no está garantizado y pueden llegar duplicados. Use detectedAt para ordenar.

Leemos de nuevo URL y clave en cada intento. Si cambia la URL, el siguiente intento usa la nueva. Tras cinco fallos, el envío se detiene y Settings → Alerts indica el motivo. Un administrador puede pulsar Retry para reenviar el último envío fallido con el mismo identificador.

Evitar duplicados

Un timeout puede ocurrir después de que su servidor haya procesado el evento. Guarde webhook-id y omita el trabajo ya realizado, respondiendo 2xx también a los duplicados. Use una restricción única o una operación atómica para evitar carreras entre dos entregas.

Confirme la recepción solo después de guardar el evento de forma duradera. Haga el trabajo lento después. Los reintentos no garantizan la entrega si el receptor falla permanentemente.

El cuerpo se vuelve a generar con los datos actuales en cada intento. Un mismo webhook-id puede llegar con precios o confidence actualizados. Evite repetir el trabajo mediante el id; si conserva el cuerpo, use el último recibido.

Sustituir la clave

Replace key, en Settings → Webhooks, invalida inmediatamente la anterior. Copie la nueva al receptor. Solo los administradores pueden hacerlo.

Durante el cambio, los envíos ya usan la nueva clave. Si el receptor responde 4xx, no se reintentan. Para conservarlos, actualice el receptor inmediatamente o responda temporalmente 5xx a firmas no verificables durante una transición controlada. La ventana dura unos dos minutos y medio, a veces poco más de uno: solo cubre un cambio rápido. Si el envío falla, pulse Retry en Settings → Alerts tras actualizar la clave para reenviar el último fallo. Sustituya una clave que pueda haberse expuesto.

Probar el endpoint

No hay botón de prueba en la aplicación. Guarde el ejemplo como event.json y firme un envío de prueba con su clave:

send-test.mjs
// node send-test.mjs https://your-server.example/stayparity
import { readFileSync } from "node:fs";
import { Webhook } from "standardwebhooks";

const id = `test_${Date.now()}`;
const event = { ...JSON.parse(readFileSync("event.json", "utf8")), id };
const body = JSON.stringify(event);
const now = new Date();

const response = await fetch(process.argv[2], {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "webhook-id": id,
    "webhook-timestamp": String(Math.floor(now.getTime() / 1000)),
    "webhook-signature": new Webhook(process.env.STAYPARITY_WEBHOOK_SECRET).sign(id, now, body),
  },
  body,
});
console.log(response.status);

Modifique un carácter del cuerpo y compruebe el rechazo. Repita un identificador y compruebe que no se procese otra vez. Para un receptor local necesita un túnel con nombre HTTPS público.

Diagnóstico

Settings → Alerts muestra la última entrega o el motivo de fallo:

  • No signing key: cree la clave.
  • Rejected: respuesta 3xx/4xx o URL no permitida.
  • Unreachable: cinco fallos de red, timeout o 5xx.
  • No webhook set: se eliminó la URL después de encolar el evento.

Tras resolverlo, Retry reenvía el último fallo. Si todas las firmas fallan, revise el cuerpo original, la clave actual, espacios añadidos al copiar y la sincronización del reloj mediante NTP.

Si no llegan eventos, revise la regla, sus umbrales y Confirmed only: un hotel comprobado solo mediante Google Hotels puede no obtener confirmaciones. No publicamos una lista fija de IP de origen; autentique mediante la firma.

En esta página