Webhook
Όταν μια OTA πουλά φθηνότερα από την απευθείας τιμή ενός ξενοδοχείου, στέλνουμε υπογεγραμμένο συμβάν JSON στον server σας. Υπογραφές, επαναλήψεις, δοκιμές.
Όταν ένας έλεγχος δείξει ότι μια OTA πουλά ένα δωμάτιο φθηνότερα από το site του ίδιου του ξενοδοχείου, το StayParity μπορεί να στείλει την απόκλιση στον server σας ως JSON. Με αυτό ανοίγετε ένα ticket, στέλνετε μήνυμα σε έναν revenue manager ή τροφοδοτείτε το δικό σας dashboard.
Οι αποστολές ακολουθούν το Standard Webhooks, οπότε τις επαληθεύει οποιαδήποτε βιβλιοθήκη Standard Webhooks. Η ίδια προδιαγραφή υπάρχει και στην περιγραφή OpenAPI 3.1.
Στήστε το endpoint
- Δημιουργήστε το κλειδί υπογραφής. Στο portal, πηγαίνετε στο Settings → Webhooks και πατήστε Create key. Το κλειδί ξεκινά με
whsec_και εμφανίζεται μόνο μία φορά. Αντιγράψτε το στις ρυθμίσεις του endpoint σας πριν κλείσετε το παράθυρο. Ένα κλειδί υπογράφει τις αποστολές όλων των ξενοδοχείων του λογαριασμού. - Προσθέστε το URL σας. Πηγαίνετε στο Settings → Alerts, ανοίξτε το όριο ειδοποίησης του ξενοδοχείου και συμπληρώστε το Webhook URL. Κάθε ξενοδοχείο έχει το δικό του URL. Αφήστε το κενό για να κλείσετε τα webhook σε αυτό το ξενοδοχείο.
- Απαντήστε με 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-signature | v1, και μετά η υπογραφή HMAC-SHA256 σε base64. Νέα σε κάθε επανάληψη. |
Σώμα
| Πεδίο | Τύπος | Τι σημαίνει |
|---|---|---|
version | 1 | Η έκδοση του payload. Στην ίδια έκδοση μπορεί να προστεθούν πεδία, οπότε αγνοήστε όσα δεν αναγνωρίζετε. |
id | string | Το id της αποστολής, ίδιο με το webhook-id. Με αυτό αναγνωρίζετε τις διπλές αποστολές και τις παραλείπετε. |
event | "disparity.opened" | Τι συνέβη. |
disparityId | string | Το id της απόκλισης στο StayParity. |
hotel.id | string | Το id του ξενοδοχείου στο StayParity. |
hotel.name | string | Το όνομα του ξενοδοχείου, όπως είναι στο portal. |
ota | string | Το κανάλι, ως slug με πεζά: booking, expedia ή ο πωλητής που έδειξε το Google Hotels, όπως trip ή agoda. |
stayDate | string | Η νύχτα, σε μορφή YYYY-MM-DD. |
occupancy | integer | Ο αριθμός ατόμων, από 1 έως 10. |
directPriceEurMinor | integer | Η απευθείας τιμή του ξενοδοχείου, σε λεπτά του ευρώ. |
otaPriceEurMinor | integer | Η τιμή του καναλιού, σε λεπτά του ευρώ. |
deltaEurMinor | integer | Πόσο φθηνότερα πουλά το κανάλι, σε λεπτά του ευρώ. |
deltaPct | number | Η ίδια διαφορά ως ποσοστό της απευθείας τιμής, στρογγυλεμένη σε δύο δεκαδικά. |
confidence | "sweep" ή "confirmed" | confirmed αν πήραμε την τιμή από τη σελίδα του ίδιου του καναλιού, sweep αν την είδαμε μέσω Google Hotels. |
detectedAt | string | Πότε είδαμε πρώτη φορά την απόκλιση, σε UTC, ως ISO 8601. |
Οι τιμές είναι ακέραιοι σε λεπτά του ευρώ, όποιο κι αν είναι το νόμισμα του ξενοδοχείου. Έτσι το 17900 σημαίνει 179,00 €. Μη διαιρείτε με το 100 σε float για να συγκρίνετε· συγκρίνετε τους ακεραίους.
Παράδειγμα
{
"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 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 (\Exception $e) {
http_response_code(401);
exit;
}
// Store it and answer now; do the slow work afterwards.
http_response_code(204);Χωρίς βιβλιοθήκη
Αν δεν μπορείτε να προσθέσετε dependency, ο έλεγχος είναι σύντομος:
- Πάρτε το κλειδί μετά το
whsec_και αποκωδικοποιήστε το από base64. Αυτά τα bytes είναι το κλειδί του HMAC. - Ενώστε το
webhook-id, τοwebhook-timestampκαι το αυτούσιο σώμα με τελείες:{id}.{timestamp}.{body}. - Υπολογίστε το HMAC-SHA256 αυτού του string και κωδικοποιήστε το σε base64.
- Το
webhook-signatureείναι μια λίστα από εγγραφέςv1,<signature>, χωρισμένες με κενό. Δεχτείτε την αποστολή αν ταιριάζει οποιαδήποτε εγγραφήv1, με σύγκριση σε σταθερό χρόνο. Σήμερα στέλνουμε μία εγγραφή. - Απορρίψτε
webhook-timestampπου απέχει πάνω από πέντε λεπτά από το ρολόι σας, προς οποιαδήποτε κατεύθυνση.
Τα ίδια βήματα σε TypeScript, μόνο με Web Crypto, ώστε να τρέχουν σε Node 20+, Bun, Deno και 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));
}
Επαναλήψεις
Απαντήστε μέσα σε 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 και μετά:
// 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 προέλευσης.
Τεκμηρίωση για developers
Πώς συνδέετε το StayParity με τα δικά σας συστήματα. Υπογεγραμμένα webhook για κάθε νέα απόκλιση και το σήμα απευθείας κράτησης για το site.
Σήμα για το site
Προσθέστε το σήμα απευθείας κράτησης του StayParity στο site ενός ξενοδοχείου με ένα script tag. Το snippet, οι επιλογές, τι βλέπει ο επισκέπτης και το CSP.