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
- 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. - En Settings → Alerts, abra la regla del hotel e indique Webhook URL. Una URL vacía desactiva sus webhooks.
- 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.
| Cabecera | Valor |
|---|---|
webhook-id | Identificador del envío, igual al id del cuerpo. Se mantiene en los reintentos. |
webhook-timestamp | Segundos Unix de la firma de este intento. Se renueva en cada intento. |
webhook-signature | v1, seguido de la firma HMAC-SHA256 en base64. Se renueva en cada intento. |
Cuerpo
| Campo | Tipo | Significado |
|---|---|---|
version | 1 | Versión del formato. Ignore campos desconocidos. |
id | string | Identificador del envío para evitar duplicados. |
event | "disparity.opened" | Evento ocurrido. |
disparityId | string | Identificador de la discrepancia. |
hotel.id | string | Identificador del hotel. |
hotel.name | string | Nombre configurado en la aplicación. |
ota | string | Canal en minúsculas: booking, expedia o proveedor de Google Hotels, como trip o agoda. |
stayDate | string | Fecha de estancia YYYY-MM-DD. |
occupancy | integer | Huéspedes, entre 1 y 10. |
directPriceEurMinor | integer | Precio directo en céntimos de euro. |
otaPriceEurMinor | integer | Precio del canal en céntimos de euro. |
deltaEurMinor | integer | Diferencia de precio en céntimos. |
deltaPct | number | Diferencia 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. |
detectedAt | string | Primera 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
{
"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 standardwebhooksimport 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 standardwebhooksimport 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 "", 204PHP
composer require standard-webhooks/standard-webhooks<?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
- Quite
whsec_y decodifique el resto desde base64: son los bytes de la clave HMAC. - Una
webhook-id,webhook-timestampy cuerpo original con puntos:{id}.{timestamp}.{body}. - Calcule HMAC-SHA256 y codifique el resultado en base64.
- La cabecera contiene entradas
v1,<firma>separadas por espacios. Acepte si alguna coincide mediante comparación en tiempo constante. Actualmente enviamos una entrada. - 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:
// 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:
| Respuesta | Resultado |
|---|---|
| 2xx | Entregado. Basta 200 o 204 vacío; no leemos el cuerpo. |
| 5xx, timeout o error de red | Se reintenta hasta un máximo de 5 intentos. |
| 3xx | Fallo sin reintento. No seguimos redirecciones. |
| 4xx | Rechazo 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:
// 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.
Documentación técnica
Integre StayParity con sus sistemas mediante webhooks firmados y añada el widget de reserva directa a la web del hotel. Claves, eventos y ejemplos.
Widget de reserva directa
Añada el widget StayParity a la web del hotel con una etiqueta script. Atributos, actualización de fechas, resultados, caché y política CSP.