Μετάβαση στο περιεχόμενο
StayParity

Webhook

Όταν μια OTA πουλά φθηνότερα από την απευθείας τιμή ενός ξενοδοχείου, στέλνουμε υπογεγραμμένο συμβάν JSON στον server σας. Υπογραφές, επαναλήψεις, δοκιμές.

Όταν ένας έλεγχος δείξει ότι μια OTA πουλά ένα δωμάτιο φθηνότερα από το site του ίδιου του ξενοδοχείου, το StayParity μπορεί να στείλει την απόκλιση στον server σας ως JSON. Με αυτό ανοίγετε ένα ticket, στέλνετε μήνυμα σε έναν revenue manager ή τροφοδοτείτε το δικό σας dashboard.

Οι αποστολές ακολουθούν το Standard Webhooks, οπότε τις επαληθεύει οποιαδήποτε βιβλιοθήκη Standard Webhooks. Η ίδια προδιαγραφή υπάρχει και στην περιγραφή OpenAPI 3.1.

Στήστε το endpoint

  1. Δημιουργήστε το κλειδί υπογραφής. Στο portal, πηγαίνετε στο Settings → Webhooks και πατήστε Create key. Το κλειδί ξεκινά με whsec_ και εμφανίζεται μόνο μία φορά. Αντιγράψτε το στις ρυθμίσεις του endpoint σας πριν κλείσετε το παράθυρο. Ένα κλειδί υπογράφει τις αποστολές όλων των ξενοδοχείων του λογαριασμού.
  2. Προσθέστε το URL σας. Πηγαίνετε στο Settings → Alerts, ανοίξτε το όριο ειδοποίησης του ξενοδοχείου και συμπληρώστε το Webhook URL. Κάθε ξενοδοχείο έχει το δικό του URL. Αφήστε το κενό για να κλείσετε τα webhook σε αυτό το ξενοδοχείο.
  3. Απαντήστε με 2xx. Επαληθεύστε την υπογραφή, αποθηκεύστε το συμβάν και απαντήστε γρήγορα (δείτε Επαναλήψεις).

Μόνο οι διαχειριστές του λογαριασμού μπορούν να δημιουργήσουν το κλειδί ή να αλλάξουν ένα webhook URL.

Το URL πρέπει να είναι δημόσια διεύθυνση https://, έως 2048 χαρακτήρες. Δεν δεχόμαστε διευθύνσεις IP, localhost, hosts με ένα μόνο label, ονόματα που τελειώνουν σε .local, .internal, .lan, .localhost ή .home.arpa, ούτε URL που περιέχουν όνομα χρήστη ή κωδικό. Αν χρειάζεστε δικό σας token, βάλτε το στο path ή στο query string.

Δεν στέλνουμε ποτέ τίποτα χωρίς υπογραφή. Όσο ο λογαριασμός δεν έχει κλειδί, τα webhook μένουν σιωπηλά, και το portal το γράφει δίπλα στο URL.

Τι στέλνουμε

Σήμερα υπάρχει ένα συμβάν, το disparity.opened. Ο κώδικάς σας πρέπει να αγνοεί κάθε event που δεν αναγνωρίζει, ώστε ένα νέο συμβάν να μην τον σπάσει.

disparity.opened

Το στέλνουμε μία φορά, όταν ένας έλεγχος βρει για πρώτη φορά μια OTA φθηνότερη από την απευθείας τιμή του ξενοδοχείου για μια διαμονή, και το όριο ειδοποίησης του ξενοδοχείου την αφήνει να περάσει. Το όριο ορίζει:

  • πόσο μεγάλη πρέπει να είναι η διαφορά: Cheaper by at least (φθηνότερα τουλάχιστον κατά) ένα ποσό σε ευρώ, and at least (και τουλάχιστον) ένα ποσοστό. Πρέπει να ισχύουν και τα δύο.
  • ποια κανάλια μετράνε, αν το όριο τα περιορίζει.
  • αν αρκεί να δούμε την τιμή μία φορά. Με το Confirmed only (μόνο επιβεβαιωμένες), μια τιμή που είδαμε μέσω Google Hotels περιμένει να τη δείξει και ένας δεύτερος έλεγχος στη σελίδα του ίδιου του καναλιού. Με το Also seen once (και όσες είδαμε μία φορά), το συμβάν φεύγει από την πρώτη φορά που βλέπουμε την τιμή.

Δεν το ξαναστέλνουμε όσο η απόκλιση μένει ανοιχτή, όσοι έλεγχοι κι αν τη δουν. Αν η απόκλιση κλείσει και αργότερα ανοίξει ξανά, είναι νέα απόκλιση, με νέο disparityId και νέο συμβάν.

Το συμβάν φεύγει μόλις τελειώσει ο έλεγχος που βρήκε την απόκλιση. Αν η απόκλιση κλείσει, ως διορθωμένη ή απορριφθείσα, πριν φύγει η αποστολή, δεν τη στέλνουμε.

Κεφαλίδες

Κάθε αποστολή είναι ένα POST με Content-Type: application/json και αυτές τις κεφαλίδες:

ΚεφαλίδαΤιμή
webhook-idΤο id της αποστολής, ίδιο με το id στο σώμα. Μένει ίδιο σε κάθε επανάληψη της ίδιας αποστολής.
webhook-timestampΠότε υπογράψαμε αυτή την προσπάθεια, σε ακέραια δευτερόλεπτα Unix. Νέο σε κάθε επανάληψη.
webhook-signaturev1, και μετά η υπογραφή HMAC-SHA256 σε base64. Νέα σε κάθε επανάληψη.

Σώμα

ΠεδίοΤύποςΤι σημαίνει
version1Η έκδοση του payload. Στην ίδια έκδοση μπορεί να προστεθούν πεδία, οπότε αγνοήστε όσα δεν αναγνωρίζετε.
idstringΤο id της αποστολής, ίδιο με το webhook-id. Με αυτό αναγνωρίζετε τις διπλές αποστολές και τις παραλείπετε.
event"disparity.opened"Τι συνέβη.
disparityIdstringΤο id της απόκλισης στο StayParity.
hotel.idstringΤο id του ξενοδοχείου στο StayParity.
hotel.namestringΤο όνομα του ξενοδοχείου, όπως είναι στο portal.
otastringΤο κανάλι, ως slug με πεζά: booking, expedia ή ο πωλητής που έδειξε το Google Hotels, όπως trip ή agoda.
stayDatestringΗ νύχτα, σε μορφή YYYY-MM-DD.
occupancyintegerΟ αριθμός ατόμων, από 1 έως 10.
directPriceEurMinorintegerΗ απευθείας τιμή του ξενοδοχείου, σε λεπτά του ευρώ.
otaPriceEurMinorintegerΗ τιμή του καναλιού, σε λεπτά του ευρώ.
deltaEurMinorintegerΠόσο φθηνότερα πουλά το κανάλι, σε λεπτά του ευρώ.
deltaPctnumberΗ ίδια διαφορά ως ποσοστό της απευθείας τιμής, στρογγυλεμένη σε δύο δεκαδικά.
confidence"sweep" ή "confirmed"confirmed αν πήραμε την τιμή από τη σελίδα του ίδιου του καναλιού, sweep αν την είδαμε μέσω Google Hotels.
detectedAtstringΠότε είδαμε πρώτη φορά την απόκλιση, σε UTC, ως ISO 8601.

Οι τιμές είναι ακέραιοι σε λεπτά του ευρώ, όποιο κι αν είναι το νόμισμα του ξενοδοχείου. Έτσι το 17900 σημαίνει 179,00 €. Μη διαιρείτε με το 100 σε float για να συγκρίνετε· συγκρίνετε τους ακεραίους.

Παράδειγμα

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

Επαληθεύστε την υπογραφή

Ελέγχετε κάθε αποστολή πριν την εμπιστευτείτε. Η υπογραφή αποδεικνύει ότι το σώμα ήρθε από το StayParity με το δικό σας κλειδί και ότι δεν άλλαξε στη διαδρομή. Το timestamp εμποδίζει κάποιον να ξαναστείλει μια αποστολή που υπέκλεψε.

Χρησιμοποιήστε την επίσημη βιβλιοθήκη Standard Webhooks για τη γλώσσα σας. Ελέγχει την υπογραφή σε σταθερό χρόνο και απορρίπτει timestamp που απέχει πάνω από πέντε λεπτά από το ρολόι σας. Δώστε της ολόκληρο το κλειδί, μαζί με το whsec_, και το σώμα αυτούσιο (raw body), δηλαδή τα bytes ακριβώς όπως έφτασαν, πριν από οποιοδήποτε parsing του JSON. Ένα σώμα που έγινε parse και μετά ξανά serialize δεν θα ταιριάζει με την υπογραφή.

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 (\Exception $e) {
    http_response_code(401);
    exit;
}
// Store it and answer now; do the slow work afterwards.
http_response_code(204);

Χωρίς βιβλιοθήκη

Αν δεν μπορείτε να προσθέσετε dependency, ο έλεγχος είναι σύντομος:

  1. Πάρτε το κλειδί μετά το whsec_ και αποκωδικοποιήστε το από base64. Αυτά τα bytes είναι το κλειδί του HMAC.
  2. Ενώστε το webhook-id, το webhook-timestamp και το αυτούσιο σώμα με τελείες: {id}.{timestamp}.{body}.
  3. Υπολογίστε το HMAC-SHA256 αυτού του string και κωδικοποιήστε το σε base64.
  4. Το webhook-signature είναι μια λίστα από εγγραφές v1,<signature>, χωρισμένες με κενό. Δεχτείτε την αποστολή αν ταιριάζει οποιαδήποτε εγγραφή v1, με σύγκριση σε σταθερό χρόνο. Σήμερα στέλνουμε μία εγγραφή.
  5. Απορρίψτε webhook-timestamp που απέχει πάνω από πέντε λεπτά από το ρολόι σας, προς οποιαδήποτε κατεύθυνση.

Τα ίδια βήματα σε TypeScript, μόνο με Web Crypto, ώστε να τρέχουν σε Node 20+, Bun, Deno και 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));
}

Επαναλήψεις

Απαντήστε μέσα σε 10 δευτερόλεπτα. Τι κάνουμε μετά εξαρτάται από την απάντηση:

Η απάντησή σαςΤι γίνεται
Οποιοδήποτε 2xxΠαραδόθηκε. Δεν διαβάζουμε το σώμα της απάντησης, οπότε αρκεί ένα κενό 200 ή 204.
5xx, λήξη χρόνου ή σφάλμα δικτύουΤο ξαναστέλνουμε, έως 5 προσπάθειες συνολικά.
3xxΑποτυχία, χωρίς επανάληψη. Δεν ακολουθούμε ανακατευθύνσεις, οπότε δώστε μας το τελικό URL.
4xxΑποτυχία, χωρίς επανάληψη. Απαντήστε 4xx μόνο όταν μια επανάληψη δεν θα βοηθούσε.

Οι αναμονές ανάμεσα στις προσπάθειες είναι περίπου 10, 20, 40 και 80 δευτερόλεπτα. Κάθε αναμονή μπορεί να μεγαλώσει ή να μικρύνει έως και κατά το μισό, ώστε οι επαναλήψεις να μη φτάνουν όλες μαζί. Κάθε προσπάθεια την υπογράφουμε ξανά με νέο timestamp, και κρατά το ίδιο webhook-id.

Οι αποστολές μπορεί να φτάσουν με οποιαδήποτε σειρά, και μία αποστολή μπορεί να φτάσει περισσότερες από μία φορές. Ταξινομήστε με βάση το detectedAt, όχι με τη σειρά άφιξης, και χειριστείτε τις διπλές αποστολές όπως περιγράφουμε παρακάτω.

Το URL και το κλειδί τα διαβάζουμε ξανά σε κάθε προσπάθεια. Αν αλλάξετε το URL ενώ εκκρεμούν επαναλήψεις, η επόμενη προσπάθεια πηγαίνει στο νέο.

Όταν αποτύχουν και οι 5 προσπάθειες, η αποστολή σταματά και το Settings → Alerts δείχνει τον λόγο. Αφού διορθώσετε τον server σας, ένας διαχειριστής του λογαριασμού μπορεί να πατήσει εκεί Retry για να ξαναστείλει την πιο πρόσφατη αποστολή που απέτυχε, με το ίδιο webhook-id.

Διπλές αποστολές

Η παράδοση γίνεται τουλάχιστον μία φορά (at least once). Μια λήξη χρόνου από τη δική μας πλευρά μπορεί να σημαίνει ότι ο server σας έκανε τη δουλειά και απλώς χάθηκε η απάντηση. Τότε η ίδια αποστολή έρχεται ξανά. Διπλή είναι και μια αποστολή που ξαναστέλνετε με το Retry στο portal.

Το webhook-id μένει ίδιο σε κάθε επανάληψη της ίδιας αποστολής. Κρατήστε τα id που έχετε ήδη χειριστεί, τουλάχιστον για λίγες μέρες. Όταν ξαναέρθει κάποιο, απαντήστε 2xx χωρίς να κάνετε ξανά τη δουλειά. Επειδή το id είναι σε κεφαλίδα, μπορείτε να το ελέγξετε πριν κάνετε parse το σώμα.

Για να ενεργείτε μία φορά ανά απόκλιση και όχι μία φορά ανά αποστολή, χρησιμοποιήστε ως κλειδί το disparityId.

Αλλαγή κλειδιού

Πηγαίνετε στο Settings → Webhooks και πατήστε Replace key. Το παλιό κλειδί σταματά να δουλεύει αμέσως· δεν υπάρχει μεταβατική περίοδος. Το νέο κλειδί εμφανίζεται μόνο μία φορά.

Από τη στιγμή που αντικαθιστάτε το κλειδί μέχρι να το περάσετε στο endpoint σας, υπογράφουμε τις αποστολές με το νέο κλειδί και το endpoint σας θα τις απορρίπτει. Αν απαντά 4xx, δεν τις ξαναστέλνουμε. Για να μη χαθούν, βάλτε αμέσως το νέο κλειδί. Αλλιώς, όσο κάνετε την αλλαγή, ρυθμίστε το endpoint να απαντά 5xx σε υπογραφή που δεν μπορεί να επαληθεύσει, ώστε να τις φέρουν οι επαναλήψεις. Οι επαναλήψεις κρατούν περίπου πέντε λεπτά συνολικά.

Αντικαταστήστε το κλειδί κάθε φορά που μπορεί να έχει διαρρεύσει: σε ένα commit, σε μια γραμμή log, σε μια κοινή οθόνη.

Δοκιμάστε το endpoint

Στο portal δεν υπάρχει ακόμη κουμπί δοκιμής. Για να δοκιμάσετε το endpoint σας, υπογράψτε μόνοι σας το παράδειγμα με το κλειδί σας και στείλτε το, ακριβώς όπως θα κάναμε εμείς. Αποθηκεύστε το παράδειγμα ως event.json και μετά:

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

Μετά ελέγξτε τις περιπτώσεις αποτυχίας. Αλλάξτε έναν χαρακτήρα στο σώμα και περιμένετε απόρριψη. Ξαναστείλτε το ίδιο id και περιμένετε το endpoint να το παραλείψει.

Αν το endpoint τρέχει τοπικά, ανοίξτε το προς τα έξω με ένα tunnel που έχει δημόσιο όνομα https://, γιατί στέλνουμε μόνο σε δημόσιες διευθύνσεις.

Αντιμετώπιση προβλημάτων

Στο Settings → Alerts, κάτω από το webhook URL κάθε ξενοδοχείου, φαίνεται πότε έγινε η τελευταία παράδοση ή γιατί απέτυχε η τελευταία αποστολή:

  • No signing key (δεν υπάρχει κλειδί υπογραφής): ο λογαριασμός δεν έχει ακόμη κλειδί. Δημιουργήστε ένα στο Settings → Webhooks.
  • Rejected (απορρίφθηκε): ο server σας απάντησε 3xx ή 4xx, ή το URL δεν επιτρέπεται πια.
  • Unreachable (μη προσβάσιμο): 5 προσπάθειες έληξαν, πήραν 5xx ή δεν μπόρεσαν να συνδεθούν.
  • No webhook set (δεν έχει οριστεί webhook): το URL αφαιρέθηκε αφού η αποστολή είχε μπει στην ουρά.

Όταν διορθώσετε την αιτία, το Retry δίπλα στην αποτυχία ξαναστέλνει την πιο πρόσφατη αποστολή που απέτυχε.

Όταν αποτυγχάνει κάθε υπογραφή:

  • Το σώμα έγινε parse πριν από την επαλήθευση. Επαληθεύστε πρώτα τα αυτούσια bytes και μετά κάντε parse.
  • Το κλειδί είναι παλιό. Μετά το Replace key δουλεύει μόνο το νέο.
  • Το κλειδί έχασε το πρόθεμά του ή πήρε κενά όταν το επικολλήσατε. Δώστε στη βιβλιοθήκη ολόκληρο το string whsec_….
  • Το ρολόι του server σας απέχει πάνω από πέντε λεπτά. Συγχρονίστε το με NTP.

Όταν δεν φτάνει τίποτα:

  • Το όριο ειδοποίησης του ξενοδοχείου είναι απενεργοποιημένο, ή καμία απόκλιση δεν έχει φτάσει ακόμη τα ελάχιστα που ορίζει.
  • Το όριο είναι σε Confirmed only και ελέγχουμε το ξενοδοχείο μόνο μέσω Google Hotels, οπότε δεν μπορούμε να επιβεβαιώσουμε καμία τιμή. Το portal σας προειδοποιεί γι' αυτό πάνω στο ίδιο το όριο.
  • Το firewall σας απορρίπτει αιτήματα που δεν αναγνωρίζει. Δεν δημοσιεύουμε σταθερή λίστα διευθύνσεων, οπότε ταυτοποιήστε τις αποστολές με την υπογραφή και όχι με την IP προέλευσης.

Σε αυτή τη σελίδα