Rate parity for booking engines and hotel groups.
We check each hotel's direct price against Booking.com, Expedia and every seller Google Hotels lists, and tell your systems when one sells for less, in a signed webhook they can act on.
Content-Type: application/json
X-StayParity-Signature: t=1791547324,v1=5f0c…e21a
{
"version": 1,
"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"
}Illustrative values. Hotel Aurora is fictional.
Any booking engine
Not tied to one engine. BON is the first we read directly. Hotels on any other engine are covered today.
Read from the engine
Where we have a connector for a hotel's engine, we read its direct price the way a guest sees it on the hotel's own site. BON is the first.
Or through Google Hotels
For a hotel on any other engine, we use the price Google Hotels shows for its official site. Nothing to install, and no access to the engine.
The next engine
Each engine is one connector behind the same checks, matching and alerts. If you build a booking engine or a channel manager, talk to us about being next.
The webhook
One signed event when an undercut opens. Posted as JSON to each hotel's own endpoint, and versioned from the first field.
The fields
- version
- Always 1 today. Fields may be added under it, so ignore any you don't know.
- event
- Always disparity.opened: a new undercut. It is the only event.
- disparityId
- The undercut's id. A retry carries the same one, so use it to skip repeats.
- hotel
- The hotel's id and name in StayParity.
- ota
- The channel, as a lower-case slug: booking, expedia, or whichever seller Google Hotels listed.
- stayDate
- The night, as YYYY-MM-DD.
- occupancy
- The guest count.
- directPriceEurMinor
- The hotel's direct price, in euro cents.
- otaPriceEurMinor
- The channel's price, in euro cents.
- deltaEurMinor
- How much less the channel charges, in euro cents.
- deltaPct
- The same gap as a percentage of the direct price, to two decimals.
- confidence
- confirmed if read on the channel's own page, sweep if seen through an aggregator.
- detectedAt
- When the undercut was first seen, in UTC.
How it's delivered
- A POST with Content-Type application/json, to a public https URL
- Delivered once your endpoint answers 2xx within 10 seconds
- A 5xx or a timeout is retried, with a longer wait each time
- A 4xx is taken as a refusal and not retried
- Redirects are not followed, so give the final URL
Which undercuts are sent
- Each hotel's alert rule decides: a minimum gap in euros and in percent, and which channels count
- Confirmed undercuts only, unless the rule also takes ones seen through an aggregator
- One event per undercut, when it opens, and not again while it stays live
- Each hotel can post to its own endpoint, or several to the same one
The signature
HMAC-SHA256 over the timestamp and the raw body. The same shape as Stripe's, which your team may know already.
X-StayParity-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>- 1Take t, the Unix time in seconds, and v1, the signature in hex.
- 2Compute HMAC-SHA256 over t, a dot, then the body exactly as it arrived, keyed with your whole secret, whsec_ included.
- 3Compare the two in constant time, and refuse a t too far from now, so a captured request can't be sent again.
- 4Each retry is signed afresh, with its own t.
One signing secret per account signs every hotel's deliveries. An admin mints it in the portal and sees it once; minting a new one replaces it.
Verifying it in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as it arrived, before JSON.parse.
// secret: your whole signing secret, whsec_ prefix included.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Accounts
Every property in one account. For a hotel group, or for the hotels you bring us as a partner.
Every hotel, its own queue
Each hotel keeps its own undercuts, alert rule and webhook endpoint, and the portal switches between them.
Your team, with roles
Admins manage the team and the signing secret. Members work the queue and set the alerts.
Set up by us
Our team adds each hotel, its alert rule and your team's access. Your hotels install nothing.
Partners
Refer your hotels, or resell StayParity. Tell us how you work with hotels, and we'll agree the terms together.
Refer
Introduce the hotels you work with. We set them up and look after them.
Resell
Offer StayParity to your hotels as part of what you already sell them.
Questions
What partners ask first. Anything else, ask us.
- Do our hotels need to be on BON?
- No. BON is the first engine we read directly, not a requirement. For a hotel on any other engine, we use the price Google Hotels shows for its official site.
- How do we get the data?
- Through the webhook: we post each new undercut to your endpoint when it opens. If you need something it doesn't carry, tell us.
- What does a hotel have to install?
- Nothing, for the checks and the alerts. The book-direct badge is one script tag on the hotel's own site, and only if it wants the badge.