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

<Callout type="warn" title="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.
</Callout>

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](https://www.standardwebhooks.com), takže je ověří kterákoli knihovna pro Standard Webhooks. Tentýž kontrakt najdete i v [popisu OpenAPI 3.1](/developers/openapi.json).

## 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](#retries)).

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čka            | Hodnota                                                                                             |
| ------------------- | --------------------------------------------------------------------------------------------------- |
| `webhook-id`        | ID doručení, stejné jako `id` v těle. Při každém opakovaném pokusu o jedno doručení stejné.         |
| `webhook-timestamp` | Kdy byl tento pokus podepsán, v celých sekundách unixového času. Při každém opakovaném pokusu nové. |
| `webhook-signature` | `v1,` a za ním podpis HMAC-SHA256 v base64. Při každém opakovaném pokusu nový.                      |

#### Tělo

| Pole                  | Typ                               | Význam                                                                                                                                                                                                                                                        |
| --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`             | `3`                               | Verze 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`. |
| `id`                  | string                            | ID doručení, stejné jako `webhook-id`. Podle něj přeskakujte opakování.                                                                                                                                                                                       |
| `event`               | `"disparity.opened"`              | Co se stalo.                                                                                                                                                                                                                                                  |
| `disparityId`         | string                            | ID disparity ve StayParity.                                                                                                                                                                                                                                   |
| `hotel.id`            | string                            | ID hotelu ve StayParity.                                                                                                                                                                                                                                      |
| `hotel.name`          | string                            | Název hotelu, jak je nastavený v portálu.                                                                                                                                                                                                                     |
| `ota`                 | string                            | Kanál jako slug malými písmeny: `booking`, `expedia` nebo prodejce, kterého uvedl Google Hotels, například `trip` nebo `agoda`.                                                                                                                               |
| `stayDate`            | string                            | Noc ve formátu `YYYY-MM-DD`.                                                                                                                                                                                                                                  |
| `occupancy`           | integer                           | Počet hostů, 1 až 10.                                                                                                                                                                                                                                         |
| `directPriceEurMinor` | integer                           | Přímá cena hotelu v eurocentech.                                                                                                                                                                                                                              |
| `otaPriceEurMinor`    | integer                           | Cena kanálu v eurocentech.                                                                                                                                                                                                                                    |
| `deltaEurMinor`       | integer                           | O kolik méně kanál účtuje, v eurocentech.                                                                                                                                                                                                                     |
| `deltaPct`            | number                            | Tentýž 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.opening`    | object                            | První načtení disparity: nabídka hotelu a nabídka prodejce, jak je popisují [Podklady](#evidence). U disparity `seen_twice` první z obou načtení.                                                                                                             |
| `evidence.latest`     | object                            | Nejnovější načtení disparity, ve stejném tvaru. Stejné jako `opening`, dokud ji pozdější kontrola nenačte znovu.                                                                                                                                              |
| `detectedAt`          | string                            | Kdy 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í.

| Pole                    | Typ                                                 | Význam                                                                                                                                                                                                                                                     |
| ----------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `priceMinor`            | integer                                             | Cena, jak ji zdroj uvedl, v nejmenších jednotkách měny `currency`.                                                                                                                                                                                         |
| `currency`              | string                                              | Měna, kterou zdroj uvedl, jako kód ISO 4217, například `EUR`.                                                                                                                                                                                              |
| `priceEurMinor`         | integer                                             | Tatáž 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.                                                                                                                                                                              |
| `readAt`                | string                                              | Kdy byla načtena, v UTC, ve formátu ISO 8601.                                                                                                                                                                                                              |
| `roomLabel`             | string, nepovinné                                   | Název pokoje, jak ho zdroj ukázal.                                                                                                                                                                                                                         |
| `roomRef`               | string, nepovinné                                   | Vlastní ID pokoje u zdroje.                                                                                                                                                                                                                                |
| `roomType.name`         | string, nepovinné                                   | Typ pokoje hotelu, na který je pokoj namapovaný, s názvem z portálu.                                                                                                                                                                                       |
| `roomType.rank`         | integer, 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.                                                                                                                                                                                                         |
| `freeCancellationUntil` | string, nepovinné                                   | Poslední den bezplatného storna ve formátu `YYYY-MM-DD`.                                                                                                                                                                                                   |
| `guests`                | integer, nepovinné                                  | Počet dospělých, pro který cena platí.                                                                                                                                                                                                                     |
| `taxesIncluded`         | boolean, nepovinné                                  | Zda cena zahrnuje daně.                                                                                                                                                                                                                                    |
| `link`                  | string, 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

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

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

### 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:

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

```

## 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 2xx                              | Doruč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ů.                                                   |
| 3xx                                           | Selhalo, bez dalšího pokusu. Přesměrování nesledujeme, proto nám dejte konečnou URL.    |
| 4xx                                           | Selhalo, 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](#example) jako `event.json` a pak:

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

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.
