# Webhook

Ricevi eventi JSON firmati quando un’OTA offre una tariffa inferiore a quella diretta. Configurazione, formato, firme, nuovi tentativi e test del ricevitore.

StayParity invia nuove discrepanze al server tramite JSON firmato, secondo i criteri di notifica dell’hotel. La firma segue [Standard Webhooks](https://www.standardwebhooks.com); il contratto è disponibile anche in [OpenAPI 3.1](/developers/openapi.json).

## Configurare un ricevitore

1. In **Settings → Webhooks**, seleziona **Create key**. Salva la chiave `whsec_…` sul server prima di chiudere: viene mostrata una volta e firma gli invii di tutti gli hotel dell’account.
2. In **Settings → Alerts**, apri la regola dell’hotel e indica **Webhook URL**. Un URL vuoto disattiva i webhook per quell’hotel.
3. Verifica la firma, salva l’evento e rispondi rapidamente con 2xx.

Solo gli amministratori possono creare chiavi o modificare l’URL. Deve essere `https://`, pubblico e di massimo 2048 caratteri. Sono rifiutati indirizzi IP, `localhost`, host con una sola etichetta, nomi che terminano in `.local`, `.internal`, `.lan`, `.localhost` o `.home.arpa`, e URL con nome utente o password. Inserisci eventuali token tuoi nel percorso o nella query.

Non inviamo eventi senza firma. Senza chiave, i webhook restano inattivi e l’applicazione lo indica accanto all’URL.

## Eventi

L’unico evento attuale è `disparity.opened`. Ignora eventi sconosciuti per consentire future estensioni.

### disparity.opened

Si genera una volta quando viene aperta una discrepanza che soddisfa la regola dell’hotel. Devono essere raggiunte entrambe le soglie: importo e percentuale. Si applicano anche i canali scelti e **Confirmed only** o **Also seen once**. La prima opzione richiede conferma sulla pagina del canale; la seconda consente rilevazioni di Google Hotels senza tale conferma.

Non si ripete finché la discrepanza resta aperta. Se viene chiusa e riaperta, avrà un altro `disparityId` e un nuovo evento. L’invio avviene al termine del controllo; se la discrepanza è corretta o scartata prima dell’invio, non viene inviata.

#### Intestazioni

Ogni invio è `POST`, con `Content-Type: application/json`.

| Intestazione        | Valore                                                                                 |
| ------------------- | -------------------------------------------------------------------------------------- |
| `webhook-id`        | Identificatore dell’invio, uguale a `id` nel corpo. Rimane uguale nei nuovi tentativi. |
| `webhook-timestamp` | Secondi Unix della firma di questo tentativo. Si rinnova a ogni tentativo.             |
| `webhook-signature` | `v1,` seguito dalla firma HMAC-SHA256 in base64. Si rinnova a ogni tentativo.          |

#### Corpo

| Campo                 | Tipo                      | Significato                                                                                 |
| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| `version`             | `1`                       | Versione del formato. Ignora campi sconosciuti.                                             |
| `id`                  | string                    | Identificatore dell’invio per evitare duplicati.                                            |
| `event`               | `"disparity.opened"`      | Evento avvenuto.                                                                            |
| `disparityId`         | string                    | Identificatore della discrepanza.                                                           |
| `hotel.id`            | string                    | Identificatore dell’hotel.                                                                  |
| `hotel.name`          | string                    | Nome configurato nell’applicazione.                                                         |
| `ota`                 | string                    | Canale in minuscolo: `booking`, `expedia` o fornitore Google Hotels, come `trip` o `agoda`. |
| `stayDate`            | string                    | Data del soggiorno `YYYY-MM-DD`.                                                            |
| `occupancy`           | integer                   | Ospiti, da 1 a 10.                                                                          |
| `directPriceEurMinor` | integer                   | Tariffa diretta in centesimi di euro.                                                       |
| `otaPriceEurMinor`    | integer                   | Tariffa del canale in centesimi di euro.                                                    |
| `deltaEurMinor`       | integer                   | Differenza tariffaria in centesimi.                                                         |
| `deltaPct`            | number                    | Differenza come percentuale della tariffa diretta, arrotondata a due decimali.              |
| `confidence`          | `"sweep"` o `"confirmed"` | Rilevazione Google Hotels o verifica sulla pagina del canale.                               |
| `detectedAt`          | string                    | Prima rilevazione in UTC, ISO 8601.                                                         |

Gli importi sono interi in centesimi di euro, anche se l’hotel usa un’altra valuta: `17900` corrisponde a 179,00 €. Confronta gli interi senza convertirli in virgola mobile.

#### Esempio

```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"
}
```

## Verificare la firma

Verifica ogni invio prima di usarlo. La firma autentica il corpo con la tua chiave. Il timestamp limita la ripetizione di messaggi vecchi; non sostituisce il controllo dei duplicati.

Usa la libreria ufficiale Standard Webhooks. Confronta a tempo costante e rifiuta timestamp distanti oltre cinque minuti dall’orologio. Passa la chiave completa, incluso `whsec_`, e il corpo originale prima di analizzare il JSON. Una nuova serializzazione cambia i byte e può invalidare 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);
```

Gli esempi verificano la firma e rispondono. Aggiungi salvataggio duraturo e controllo dei duplicati prima di confermare la ricezione in produzione.

### Senza libreria

1. Rimuovi `whsec_` e decodifica il resto da base64: sono i byte della chiave HMAC.
2. Unisci `webhook-id`, `webhook-timestamp` e corpo originale con punti: `{id}.{timestamp}.{body}`.
3. Calcola HMAC-SHA256 e codifica il risultato in base64.
4. L’intestazione contiene elementi `v1,<firma>` separati da spazi. Accetta se uno coincide tramite confronto a tempo costante. Attualmente inviamo un elemento.
5. Rifiuta timestamp distanti più di cinque minuti, in entrambe le direzioni.

Questo esempio TypeScript usa Web Crypto e funziona in Node 20+, Bun, Deno e 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));
}

```

## Nuovi tentativi

Rispondi entro dieci secondi:

| Risposta                      | Risultato                                                                                |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| 2xx                           | Consegnato. Basta `200` o `204` vuoto; non leggiamo il corpo.                            |
| 5xx, timeout o errore di rete | Nuovi tentativi, fino a 5 complessivi.                                                   |
| 3xx                           | Fallimento senza nuovi tentativi. Non seguiamo reindirizzamenti.                         |
| 4xx                           | Rifiuto senza nuovi tentativi. Usalo solo quando ripetere non può risolvere il problema. |

Gli intervalli sono circa 10, 20, 40 e 80 secondi, con variazione fino alla metà in entrambe le direzioni. Ogni tentativo mantiene `webhook-id` e ha timestamp e firma nuovi. L’ordine di arrivo non è garantito e possono esserci duplicati. Usa `detectedAt` per ordinare.

URL e chiave vengono riletti a ogni tentativo. Se cambi l’URL, il prossimo usa il nuovo. Dopo cinque fallimenti l’invio si ferma e **Settings → Alerts** indica il motivo. Un amministratore può premere **Retry** per ripetere l’ultimo invio fallito con lo stesso identificatore.

## Evitare duplicati

Un timeout può verificarsi dopo che il server ha elaborato l’evento. Salva `webhook-id` e salta il lavoro già svolto, rispondendo 2xx anche ai duplicati. Usa un vincolo univoco o un’operazione atomica per evitare elaborazioni concorrenti.

Conferma la ricezione solo dopo aver salvato l’evento in modo duraturo. Esegui il lavoro lento dopo. I nuovi tentativi non garantiscono la consegna se il ricevitore resta indisponibile.

Il corpo viene ricreato dai dati attuali a ogni tentativo. Lo stesso `webhook-id` può arrivare con prezzi o `confidence` aggiornati. Evita di ripetere il lavoro tramite l’id; se conservi il corpo, usa l’ultimo ricevuto.

## Sostituire la chiave

**Replace key**, in **Settings → Webhooks**, invalida subito la precedente. Copia la nuova nel ricevitore. Solo gli amministratori possono farlo.

Durante il cambio gli invii usano già la nuova chiave. Se il ricevitore risponde 4xx, non vengono ritentati. Per conservarli, aggiorna subito il ricevitore o rispondi temporaneamente 5xx alle firme non verificabili durante una transizione controllata. La finestra dura circa due minuti e mezzo, a volte poco più di uno: copre solo un cambio rapido. Se l’invio fallisce, premi **Retry** in **Settings → Alerts** dopo aver aggiornato la chiave per ripetere l’ultimo fallimento. Sostituisci una chiave che potrebbe essere stata esposta.

## Testare l’endpoint

Non esiste un pulsante di test nell’applicazione. Salva l’[esempio](#example) come `event.json` e firma un invio di prova con la chiave:

```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);
```

Modifica un carattere del corpo e verifica il rifiuto. Ripeti un identificatore e verifica che non venga elaborato di nuovo. Per un ricevitore locale serve un tunnel con nome HTTPS pubblico.

## Diagnostica

**Settings → Alerts** mostra l’ultima consegna o il motivo del fallimento:

* **No signing key**: crea la chiave.
* **Rejected**: risposta 3xx/4xx o URL non consentito.
* **Unreachable**: cinque errori di rete, timeout o 5xx.
* **No webhook set**: l’URL è stato rimosso dopo l’accodamento.

Dopo la correzione, **Retry** ripete l’ultimo invio fallito. Se tutte le firme falliscono, verifica corpo originale, chiave attuale, spazi aggiunti durante la copia e sincronizzazione dell’orologio con NTP.

Se non arrivano eventi, controlla regola, soglie e **Confirmed only**: un hotel verificato solo tramite Google Hotels potrebbe non avere conferme. Non pubblichiamo un elenco fisso di IP sorgente; autentica tramite firma.
