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; il contratto è disponibile anche in OpenAPI 3.1.
Configurare un ricevitore
- 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. - In Settings → Alerts, apri la regola dell’hotel e indica Webhook URL. Un URL vuoto disattiva i webhook per quell’hotel.
- 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
{
"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
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);Gli esempi verificano la firma e rispondono. Aggiungi salvataggio duraturo e controllo dei duplicati prima di confermare la ricezione in produzione.
Senza libreria
- Rimuovi
whsec_e decodifica il resto da base64: sono i byte della chiave HMAC. - Unisci
webhook-id,webhook-timestampe corpo originale con punti:{id}.{timestamp}.{body}. - Calcola HMAC-SHA256 e codifica il risultato in base64.
- L’intestazione contiene elementi
v1,<firma>separati da spazi. Accetta se uno coincide tramite confronto a tempo costante. Attualmente inviamo un elemento. - 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:
// 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 come event.json e firma un invio di prova con la chiave:
// 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.
Documentazione tecnica
Integra StayParity con i tuoi sistemi tramite webhook firmati e aggiungi il widget per prenotazioni dirette al sito dell’hotel. Chiavi, eventi ed esempi.
Widget per prenotazioni dirette
Aggiungi il widget StayParity al sito dell’hotel con un tag script. Attributi, aggiornamento delle date, risultati, cache e configurazione CSP.