Webhooks
StayParity posts a signed JSON event to your server when an OTA undercuts a hotel's direct price. Events, signatures, retries and testing.
When a check finds an OTA selling a room for less than the hotel's own website, StayParity can post the undercut to your server as JSON. Use it to open a ticket, message a revenue manager or feed your own dashboard.
Deliveries follow Standard Webhooks, so any Standard Webhooks library can verify them. The same contract is in the OpenAPI 3.1 description.
Set up a receiver
- Create the signing key. In the portal, go to Settings → Webhooks and choose Create key. The key starts with
whsec_and is shown once, so copy it into your receiver's config before you close the dialog. One key signs deliveries for every hotel on the account. - Add your URL. Go to Settings → Alerts, open the hotel's alert rule and fill in Webhook URL. Each hotel has its own URL. Leave it empty to turn webhooks off for that hotel.
- Answer with a 2xx. Verify the signature, store the event, and reply quickly (see Retries).
Only account admins can create the key or change a webhook URL.
The URL has to be a public https:// address of at most 2048 characters. We refuse IP addresses, localhost, single-label hosts, names ending in .local, .internal, .lan, .localhost or .home.arpa, and URLs with a username or password in them. Put any token of your own in the path or the query string instead.
We never send an unsigned delivery. Until the account has a key, webhooks stay silent, and the portal says so next to the URL.
What gets sent
There is one event today, disparity.opened. Your code should ignore any event it doesn't know, so new events don't break it.
disparity.opened
Sent once when a check first finds an OTA cheaper than the hotel's direct price for a stay, and the hotel's alert rule lets it through. The rule decides:
- how big the gap has to be: Cheaper by at least an amount in euros, and at least a percentage. Both have to be met.
- which channels count, if the rule limits them.
- whether one reading is enough. With Confirmed only, a price seen through Google Hotels waits for a second check on the channel's own page to agree. With Also seen once, it goes out on the first reading.
It is not sent again while that undercut stays open, however many checks see it. When the undercut closes and later opens again, that is a new undercut with a new disparityId, and a new event.
It goes out as soon as the check that found it finishes. If the undercut is fixed or dismissed before the delivery goes out, it is not sent.
Headers
Every delivery is a POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
webhook-id | The delivery's id, the same as id in the body. The same on every retry of one delivery. |
webhook-timestamp | When this attempt was signed, in whole Unix seconds. New on every retry. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. New on every retry. |
Body
| Field | Type | Meaning |
|---|---|---|
version | 1 | The payload version. Fields may be added under it, so ignore any you don't know. |
id | string | The delivery's id, the same as webhook-id. Use it to skip repeats. |
event | "disparity.opened" | What happened. |
disparityId | string | The undercut's id in StayParity. |
hotel.id | string | The hotel's id in StayParity. |
hotel.name | string | The hotel's name, as set in the portal. |
ota | string | The channel as a lower-case slug: booking, expedia, or the seller Google Hotels listed, such as trip or agoda. |
stayDate | string | The night, as YYYY-MM-DD. |
occupancy | integer | The number of guests, 1 to 10. |
directPriceEurMinor | integer | The hotel's direct price, in euro cents. |
otaPriceEurMinor | integer | The channel's price, in euro cents. |
deltaEurMinor | integer | How much less the channel charges, in euro cents. |
deltaPct | number | The same gap as a percentage of the direct price, rounded to two decimals. |
confidence | "sweep" or "confirmed" | confirmed if the price was read on the channel's own page, sweep if it was seen through Google Hotels. |
detectedAt | string | When the undercut was first seen, in UTC, as ISO 8601. |
Prices are integers in euro cents, whatever the hotel's own currency, so 17900 is €179.00. Don't divide by 100 into a float and compare; compare the integers.
Example
{
"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"
}Verify the signature
Check every delivery before you trust it. The signature proves the body came from StayParity with your key and wasn't changed on the way. The timestamp stops someone replaying a delivery they captured.
Use the official Standard Webhooks library for your language. It checks the signature in constant time and rejects timestamps more than five minutes from your clock. Give it the whole key, whsec_ included, and the raw body: the exact bytes that arrived, before any JSON parsing. A body parsed and serialised again won't match the signature.
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);Without a library
If you can't add a dependency, the check is short:
- Take the key after
whsec_and base64-decode it. Those bytes are the HMAC key. - Join
webhook-id,webhook-timestampand the raw body with dots:{id}.{timestamp}.{body}. - Compute the HMAC-SHA256 of that string and base64-encode it.
webhook-signatureis a space-separated list ofv1,<signature>entries. Accept the delivery if anyv1entry matches, compared in constant time. We send one entry today.- Reject a
webhook-timestampmore than five minutes from your clock, in either direction.
The same steps in TypeScript, with Web Crypto only, so it runs in Node 20+, Bun, Deno and 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));
}
Retries
Answer within 10 seconds. What we do next depends on the answer:
| Your answer | What happens |
|---|---|
| Any 2xx | Delivered. We don't read the response body, so an empty 200 or 204 is enough. |
| 5xx, a timeout, or a network error | Tried again, up to 5 attempts in all. |
| 3xx | Failed, not retried. Redirects are not followed, so give us the final URL. |
| 4xx | Failed, not retried. Answer 4xx only when a retry can't help. |
The waits between attempts are about 10, 20, 40 and 80 seconds. Each wait varies by up to half either way, so retries don't arrive in lockstep. Every attempt is signed again with a new timestamp, and keeps the same webhook-id.
Deliveries can arrive in any order, and one delivery can arrive more than once. Sort by detectedAt, not by arrival, and handle repeats as below.
The URL and the key are read again on each attempt. If you change the URL while retries are pending, the next attempt goes to the new one.
When all 5 attempts fail, the delivery stops and Settings → Alerts shows why. Once your server is fixed, an account admin can press Retry there to send the newest failed delivery again, with the same webhook-id.
Handle repeats
Delivery is at least once. A timeout on our side can mean your server did the work and the answer was lost, and then the same delivery comes again. A delivery sent again with Retry in the portal repeats too.
webhook-id stays the same on every retry of one delivery. Keep the ids you've handled, for a few days at least, and answer 2xx without doing the work again when one comes back. Because the id is in a header, you can check it before you parse the body.
To act once per undercut rather than once per delivery, key on disparityId.
Rotate the key
Go to Settings → Webhooks and choose Replace key. The old key stops working immediately; there is no overlap period. The new key is shown once.
Between replacing the key and deploying it to your receiver, deliveries are signed with the new key and your receiver will reject them. If it answers 4xx, those deliveries are not retried. To keep them, put the new key in place right away, or have your receiver answer 5xx for a signature it can't verify while you switch, so the retries carry them over. The retries last about five minutes in all.
Replace the key whenever it may have leaked: a commit, a log line, a shared screen.
Test your endpoint
There is no test button in the portal yet. To try your receiver, sign the example event yourself with your key and post it, exactly as we would. Save the example as event.json, then:
// 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);Then check the unhappy paths: change one character of the body and expect a rejection; resend the same id and expect it to be skipped.
For a local receiver, expose it through a tunnel with a public https:// name, since we only post to public addresses.
Troubleshooting
Settings → Alerts shows, under each hotel's webhook URL, when it last delivered, or why the last delivery failed:
- No signing key: the account has no key yet. Create one under Settings → Webhooks.
- Rejected: your server answered 3xx or 4xx, or the URL is no longer allowed.
- Unreachable: 5 attempts timed out, answered 5xx or couldn't connect.
- No webhook set: the URL was removed after the delivery was queued.
When the cause is fixed, Retry next to the failure sends the newest failed delivery again.
When every signature fails:
- The body was parsed before verifying. Verify the raw bytes, then parse.
- The key is an old one. After Replace key, only the new key works.
- The key lost its prefix or gained whitespace when pasted. Pass the whole
whsec_…string to the library. - Your server's clock is off by more than five minutes. Sync it with NTP.
When nothing arrives:
- The hotel's alert rule is off, or no undercut has cleared its thresholds yet.
- The rule is on Confirmed only and the hotel is checked through Google Hotels alone, so no price can be confirmed. The portal warns about this on the rule.
- Your firewall drops requests it doesn't know. We don't publish a fixed list of addresses, so authenticate with the signature rather than the source IP.
Developer docs
How to connect StayParity to your own systems. Signed webhooks for every new undercut, and the book-direct badge for a hotel's website.
Website badge
Add StayParity's book-direct badge to a hotel's website with one script tag. The snippet, its options, what guests see, and the CSP it needs.