# 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](https://www.standardwebhooks.com); el contrato también está en [OpenAPI 3.1](/developers/openapi.json).

## 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`.

| 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

```json title="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

```sh
npm install express standardwebhooks
```

```js title="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

```sh
pip install flask standardwebhooks
```

```python title="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

```sh
composer require standard-webhooks/standard-webhooks
```

```php title="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:

```ts title="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:

| 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](#example) como `event.json` y firme un envío de prueba con su clave:

```js title="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.
