Přejít na obsah
StayParity

Webhooky

Zdokumentováno před spuštěním: podepsaná událost JSON, kterou StayParity pošle, když OTA prodává levněji než hotel přímo. Podpisy, opakované pokusy, testování.

Zatím není v provozu

Tato stránka popisuje webhook ještě před spuštěním. Dnes webhooky nedostává žádný hotel: zjištění se k hotelům dostávají přes frontu v portálu a v denním nebo týdenním souhrnu e-mailem. Dnešní zjištění jsou typu „Levněji na Googlu“: dvě načtení z Google Hotels se shodují v tom, že prodejce nabídl za stejnou noc a stejný počet hostů nižší cenu. Google pokoj neuvádí a na vlastní stránce OTA potvrzeno nic není. Až bude webhook v provozu, bude posílat jen potvrzené cenové disparity.

Až bude webhook v provozu, StayParity pošle každou potvrzenou cenovou disparitu na váš server jako JSON: OTA, jejíž vlastní stránka prodává pokoj hotelu levněji než web hotelu. Podle ní můžete založit tiket, dát vědět revenue manažerovi nebo ji poslat do vlastního dashboardu.

Doručení se budou řídit specifikací Standard Webhooks, takže je ověří kterákoli knihovna pro Standard Webhooks. Tentýž kontrakt najdete i v popisu OpenAPI 3.1.

Nastavení příjemce

Až bude webhook v provozu, nastavíte příjemce ve třech krocích:

  1. Vytvořte podpisový klíč. V portálu přejděte do Settings → Webhooks a zvolte Create key. Klíč začíná na whsec_ a zobrazí se jen jednou, proto si ho před zavřením dialogu zkopírujte do konfigurace příjemce. Jeden klíč podepisuje doručení pro všechny hotely v účtu.
  2. Přidejte svou URL. S vybraným hotelem přejděte do Settings → Alerts, v části Where alerts go zvolte Add (Set up alerts, pokud hotel ještě nemá žádné pravidlo) a vyplňte Webhook URL. Každý hotel má vlastní URL. Když pole necháte prázdné, webhooky se pro daný hotel vypnou.
  3. Odpovězte kódem 2xx. Ověřte podpis, uložte událost a odpovězte rychle (viz Opakované pokusy).

Vytvořit klíč nebo změnit URL webhooku mohou jen správci účtu.

URL musí být veřejná adresa https:// na výchozím portu a mít nejvýše 2 048 znaků. Odmítáme jakýkoli jiný port, IP adresy, localhost, názvy hostitelů bez tečky, názvy končící na .local, .internal, .lan, .localhost nebo .home.arpa a URL, které obsahují uživatelské jméno nebo heslo. Vlastní token proto dejte do cesty nebo do query stringu.

Nepodepsané doručení nikdy neodešleme. Dokud účet nemá klíč, webhooky mlčí a portál to uvádí vedle URL.

Co se odesílá

Při spuštění bude existovat jediná událost, disparity.opened. Váš kód by měl ignorovat každý event, který nezná, aby ho nové události nerozbily.

disparity.opened

Odešle se jednou, když kontrola poprvé najde OTA, která je pro daný pobyt levnější než přímá cena hotelu, a zároveň platí všechno z tohoto:

  • rozdíl splňuje pravidlo upozornění hotelu: Cheaper by at least (částka v eurech) and at least (procento). Splněné musí být obojí.
  • prodejce patří mezi ty, které hotel sleduje.
  • nižší cena byla potvrzena na vlastní stránce prodejce, u stejného pokoje: cenová disparita. Zjištění „Levněji na Googlu“, založené jen na načteních z Google Hotels, událost nikdy neodešle.

Dokud disparita zůstává otevřená, znovu se neodesílá, ať ji uvidí kolik kontrol chce. Disparita, která se otevřela pod prahy a později je překročí, odešle svou jedinou událost až v tu chvíli, s reason nastaveným na now_over_threshold. Když se disparita uzavře a později znovu otevře, je to nová disparita s novým disparityId a novou událostí.

Odešle se, jakmile doběhne kontrola, která disparitu našla. Pokud je disparita opravena nebo zamítnuta dřív, než doručení odejde, událost se neodešle.

Hlavičky

Každé doručení je POST s Content-Type: application/json a těmito hlavičkami:

HlavičkaHodnota
webhook-idID doručení, stejné jako id v těle. Při každém opakovaném pokusu o jedno doručení stejné.
webhook-timestampKdy byl tento pokus podepsán, v celých sekundách unixového času. Při každém opakovaném pokusu nové.
webhook-signaturev1, a za ním podpis HMAC-SHA256 v base64. Při každém opakovaném pokusu nový.

Tělo

PoleTypVýznam
version3Verze payloadu. Pod ní mohou přibývat pole, takže ta, která neznáte, ignorujte. Verze 2 nahradila confidence polem trust a přidala reason. Verze 3 přidala findingKind a evidence, takže příjemce, který přijímal jen 2, teď musí přijímat i 3.
idstringID doručení, stejné jako webhook-id. Podle něj přeskakujte opakování.
event"disparity.opened"Co se stalo.
disparityIdstringID disparity ve StayParity.
hotel.idstringID hotelu ve StayParity.
hotel.namestringNázev hotelu, jak je nastavený v portálu.
otastringKanál jako slug malými písmeny: booking, expedia nebo prodejce, kterého uvedl Google Hotels, například trip nebo agoda.
stayDatestringNoc ve formátu YYYY-MM-DD.
occupancyintegerPočet hostů, 1 až 10.
directPriceEurMinorintegerPřímá cena hotelu v eurocentech.
otaPriceEurMinorintegerCena kanálu v eurocentech.
deltaEurMinorintegerO kolik méně kanál účtuje, v eurocentech.
deltaPctnumberTentýž rozdíl v procentech přímé ceny, zaokrouhlený na dvě desetinná místa.
trust"confirmed" nebo "seen_twice"confirmed, pokud byla cena načtena na vlastní stránce kanálu; seen_twice, pokud dvě organická (nesponzorovaná) načtení z Google Hotels s odstupem nejméně 30 minut a na stejném základě ukázala cenu hotelu i prodejce v rozmezí 1 % od prvního načtení.
reason"now_over_threshold", nepovinnéJe přítomné, jen když byla disparita otevřená už pod prahy pravidla a od té doby je překročila. U nové disparity chybí.
findingKind"rate_disparity"O jaký druh zjištění jde. Dnes vždy rate_disparity: stejný pokoj za stejných podmínek, u prodejce levněji.
evidence.openingobjectPrvní načtení disparity: nabídka hotelu a nabídka prodejce, jak je popisují Podklady. U disparity seen_twice první z obou načtení.
evidence.latestobjectNejnovější načtení disparity, ve stejném tvaru. Stejné jako opening, dokud ji pozdější kontrola nenačte znovu.
detectedAtstringKdy byla disparita poprvé zjištěna, v UTC, ve formátu ISO 8601.

Ceny jsou celá čísla v nejmenších jednotkách měny: pole EurMinor v eurocentech bez ohledu na vlastní měnu hotelu, takže 17900 je 179,00 €, a priceMinor jedné strany podkladů v měně, kterou uvádí její currency. Nedělte je stem na desetinné číslo, abyste je porovnali; porovnávejte celá čísla.

Podklady

Každé načtení má dvě strany, direct (nabídka hotelu) a seller (nabídka prodejce), se stejnými poli. Pole, které zdroj neuvedl, chybí.

PoleTypVýznam
priceMinorintegerCena, jak ji zdroj uvedl, v nejmenších jednotkách měny currency.
currencystringMěna, kterou zdroj uvedl, jako kód ISO 4217, například EUR.
priceEurMinorintegerTatáž cena v eurocentech.
source"booking_engine", "google" nebo "seller_page"Kde byla načtena: v rezervačním systému hotelu, v seznamu cen Google Hotels, nebo na vlastní stránce prodejce.
placement"organic" nebo "ad", nepovinnéKde ji Google Hotels uvedl: ve svém seznamu cen, nebo na sponzorované pozici.
readAtstringKdy byla načtena, v UTC, ve formátu ISO 8601.
roomLabelstring, nepovinnéNázev pokoje, jak ho zdroj ukázal.
roomRefstring, nepovinnéVlastní ID pokoje u zdroje.
roomType.namestring, nepovinnéTyp pokoje hotelu, na který je pokoj namapovaný, s názvem z portálu.
roomType.rankinteger, nepovinnéPořadí tohoto typu pokoje mezi typy pokojů hotelu, počítané od základního pokoje nahoru: vyšší číslo znamená lepší pokoj.
board"room_only" nebo "breakfast", nepovinnéCo podle zdroje cena zahrnuje: jen pokoj, nebo i snídani.
mappedBoard"room_only" nebo "breakfast", nepovinnéCo cena zahrnuje podle mapování pokojů hotelu: na straně hotelu stravování jeho cenové kategorie, když ho zdroj neuvedl; na straně prodejce stravování té cenové kategorie hotelu, na kterou je namapovaná cenová kategorie prodejce.
cancellation"free", "no_free_cancellation" nebo "unknown"Storno podmínky. unknown, když je zdroj neuvedl.
freeCancellationUntilstring, nepovinnéPoslední den bezplatného storna ve formátu YYYY-MM-DD.
guestsinteger, nepovinnéPočet dospělých, pro který cena platí.
taxesIncludedboolean, nepovinnéZda cena zahrnuje daně.
linkstring, nepovinnéKam nabídka vede. U google stránka, na kterou vede záznam v Google Hotels, tedy web prodejce nebo vlastní web hotelu: cena byla načtena na Googlu, ne tam. U booking_engine a seller_page stránka, na které byla cena načtena, pokud ji zdroj uvádí.

Příklad

disparity.opened
{
  "version": 3,
  "id": "mh7d0xq4c2v9n8b1k6t3r5w0z2y4p8s6",
  "event": "disparity.opened",
  "disparityId": "jn7cq2b1x9d8w4fzr3m6kt5v0h7s2e8a",
  "hotel": { "id": "kd72mbx0vy4p9s1c6tqh8wf3gr5n2j7e", "name": "Hotel Aurora" },
  "ota": "booking",
  "stayDate": "2026-10-16",
  "occupancy": 2,
  "directPriceEurMinor": 18800,
  "otaPriceEurMinor": 17900,
  "deltaEurMinor": 900,
  "deltaPct": 4.79,
  "trust": "confirmed",
  "findingKind": "rate_disparity",
  "evidence": {
    "opening": {
      "direct": {
        "priceMinor": 18800,
        "currency": "EUR",
        "priceEurMinor": 18800,
        "source": "booking_engine",
        "readAt": "2026-10-09T12:01:12.000Z",
        "roomLabel": "Double Room with Sea View",
        "mappedBoard": "breakfast",
        "cancellation": "free"
      },
      "seller": {
        "priceMinor": 17900,
        "currency": "EUR",
        "priceEurMinor": 17900,
        "source": "seller_page",
        "readAt": "2026-10-09T12:01:47.000Z",
        "roomLabel": "Double Room - Sea View",
        "board": "breakfast",
        "cancellation": "free"
      }
    },
    "latest": {
      "direct": {
        "priceMinor": 18800,
        "currency": "EUR",
        "priceEurMinor": 18800,
        "source": "booking_engine",
        "readAt": "2026-10-09T12:01:12.000Z",
        "roomLabel": "Double Room with Sea View",
        "mappedBoard": "breakfast",
        "cancellation": "free"
      },
      "seller": {
        "priceMinor": 17900,
        "currency": "EUR",
        "priceEurMinor": 17900,
        "source": "seller_page",
        "readAt": "2026-10-09T12:01:47.000Z",
        "roomLabel": "Double Room - Sea View",
        "board": "breakfast",
        "cancellation": "free"
      }
    }
  },
  "detectedAt": "2026-10-09T12:02:00.000Z"
}

Ověření podpisu

Každé doručení ověřte dřív, než mu začnete věřit. Podpis dokazuje, že tělo přišlo od StayParity s vaším klíčem a cestou se nezměnilo. Časové razítko brání tomu, aby někdo zachycené doručení odeslal znovu.

Použijte oficiální knihovnu Standard Webhooks pro svůj jazyk. Podpis ověřuje v konstantním čase a odmítá časová razítka, která se od vašich hodin liší o víc než pět minut. Předejte jí celý klíč včetně whsec_ a surové tělo: přesně ty bajty, které dorazily, ještě před jakýmkoli parsováním JSON. Tělo, které se naparsuje a znovu serializuje, podpisu odpovídat nebude.

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

Bez knihovny

Pokud nemůžete přidat závislost, ověření je krátké:

  1. Vezměte část klíče za whsec_ a dekódujte ji z base64. Tyto bajty jsou klíč pro HMAC.
  2. Spojte webhook-id, webhook-timestamp a surové tělo tečkami: {id}.{timestamp}.{body}.
  3. Spočítejte HMAC-SHA256 tohoto řetězce a zakódujte ho do base64.
  4. webhook-signature je seznam položek v1,<signature> oddělených mezerou. Doručení přijměte, pokud odpovídá kterákoli položka v1, porovnaná v konstantním čase. Dnes posíláme jednu položku.
  5. Odmítněte webhook-timestamp, který se od vašich hodin liší o víc než pět minut, v kterémkoli směru.

Tytéž kroky v TypeScriptu, jen s Web Crypto, takže kód běží v Node 20+, Bun, Deno i 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));
}

Opakované pokusy

Odpovězte do 10 sekund. Co uděláme dál, záleží na odpovědi:

Vaše odpověďCo se stane
Jakýkoli kód 2xxDoručeno. Tělo odpovědi nečteme, takže stačí prázdná 200 nebo 204.
5xx, vypršení časového limitu nebo chyba sítěDalší pokus, celkem nejvýše 5 pokusů.
3xxSelhalo, bez dalšího pokusu. Přesměrování nesledujeme, proto nám dejte konečnou URL.
4xxSelhalo, bez dalšího pokusu. Kódem 4xx odpovídejte, jen když další pokus nemůže pomoci.

Čekání mezi pokusy trvá zhruba 10, 20, 40 a 80 sekund. Každé čekání se může lišit až o polovinu oběma směry, aby opakované pokusy nepřicházely ve stejném taktu. Každý pokus se znovu podepíše s novým časovým razítkem a ponechá si stejné webhook-id.

Doručení mohou dorazit v libovolném pořadí a jedno doručení může dorazit vícekrát. Řaďte podle detectedAt, ne podle pořadí příchodu, a opakování ošetřete tak, jak popisujeme níže.

URL a klíč se při každém pokusu načítají znovu. Pokud URL změníte, zatímco opakované pokusy čekají, další pokus půjde už na novou.

Když selže všech 5 pokusů, doručení skončí a Settings → Alerts ukáže proč. Jakmile bude váš server opravený, může tam správce účtu stisknout Retry a nejnovější neúspěšné doručení odeslat znovu, se stejným webhook-id.

Ošetření opakování

Doručení probíhá nejméně jednou. Vypršení časového limitu na naší straně může znamenat, že váš server práci udělal a ztratila se jen odpověď, a pak totéž doručení přijde znovu. Opakováním je i doručení, které se znovu odešle tlačítkem Retry v portálu.

webhook-id zůstává stejné při každém opakovaném pokusu o jedno doručení. Ukládejte si ID, která jste zpracovali, aspoň na několik dní, a když se některé vrátí, odpovězte 2xx a práci neopakujte. Protože je ID v hlavičce, můžete ho zkontrolovat ještě před parsováním těla.

Tělo při každém pokusu sestavujeme znovu z aktuální disparity, takže opakování se stejným webhook-id může nést novější ceny nebo novější trust než to první. Opakování přeskakujte podle webhook-id, a pokud si tělo ukládáte, za aktuální považujte to, které jste přijali naposledy.

Chcete-li reagovat jednou na každou disparitu, a ne jednou na každé doručení, použijte jako klíč disparityId.

Výměna klíče

Přejděte do Settings → Webhooks a zvolte Replace key. Starý klíč okamžitě přestane fungovat; žádné přechodné období není. Nový klíč se zobrazí jednou.

Mezi nahrazením klíče a jeho nasazením u příjemce se doručení podepisují novým klíčem a váš příjemce je odmítne. Pokud odpoví 4xx, tato doručení se znovu nezkoušejí. Chcete-li o ně nepřijít, nasaďte nový klíč hned, nebo nechte příjemce během výměny odpovídat 5xx na podpis, který neumí ověřit, aby je přenesly opakované pokusy. Opakované pokusy zaberou zhruba dvě a půl minuty, někdy jen o málo víc než jednu, takže pokryjí jen rychlou výměnu. Pro doručení, které přesto selže, stiskněte Retry v Settings → Alerts, jakmile bude nový klíč nasazený. Tím se nejnovější neúspěšné doručení odešle znovu.

Klíč nahraďte vždy, když mohl uniknout: commit, řádek v logu, sdílená obrazovka.

Test endpointu

V portálu zatím není tlačítko pro test. Chcete-li příjemce vyzkoušet, podepište událost z příkladu vlastním klíčem a pošlete ji přesně tak, jak bychom to udělali my. Uložte příklad jako event.json a pak:

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

Pak vyzkoušejte chybové případy: změňte v těle jeden znak a očekávejte odmítnutí; pošlete stejné ID znovu a očekávejte, že se přeskočí.

Lokálního příjemce zpřístupněte přes tunel s veřejným názvem https://, protože posíláme jen na veřejné adresy.

Řešení potíží

Settings → Alerts ukazuje pod URL webhooku každého hotelu, kdy naposledy proběhlo doručení, nebo proč poslední doručení selhalo:

  • No signing key: účet ještě nemá klíč. Vytvořte ho v Settings → Webhooks.
  • Rejected: váš server odpověděl 3xx nebo 4xx, nebo URL už není povolená.
  • Unreachable: 5 pokusů skončilo vypršením časového limitu, odpovědí 5xx nebo nepodařeným spojením.
  • No webhook set: URL byla odstraněna nebo bylo vypnuto Alerting až poté, co se doručení zařadilo do fronty.

Po odstranění příčiny odešle Retry vedle chyby nejnovější neúspěšné doručení znovu.

Když selhávají všechny podpisy:

  • Tělo se naparsovalo před ověřením. Ověřte surové bajty a parsujte až potom.
  • Klíč je starý. Po Replace key funguje jen nový klíč.
  • Klíč při vkládání ztratil prefix nebo k němu přibyly mezery. Předejte knihovně celý řetězec whsec_….
  • Hodiny vašeho serveru se liší o víc než pět minut. Synchronizujte je přes NTP.

Když nic nepřichází:

  • Webhook ještě není v provozu. Dokud nebude, žádné doručení neodejde.
  • Pravidlo upozornění hotelu je vypnuté, nebo zatím žádná disparita nepřekročila jeho prahy.
  • Dosavadní disparity jsou od prodejců, které hotel nesleduje, nebo jde o zjištění „Levněji na Googlu“, která událost nikdy neodesílají.
  • Váš firewall zahazuje požadavky, které nezná. Pevný seznam adres nezveřejňujeme, proto ověřujte podle podpisu, ne podle zdrojové IP.

Na této stránce