Webhooks
Doorbell, not the postman. Webhooks lower your latency without becoming the point of failure. HMAC-SHA256 signed on every request, tunable retry ladder, deliveries introspectable via API, per-property or per-account filters, one-shot signing-secret rotation. And because the revisions feed is the source of truth, a missed webhook is a latency cost — not a data loss. Why Quendoo Connect explains why we chose this shape instead of a fire-and-forget push.
Every state change on your side of QC — a booking arrives, an inventory push completes, a rate-plan change takes effect — can be pushed to an HTTP endpoint you own. Webhooks are a doorbell, not the source of truth: their job is to lower your polling latency without making you rely on us for correctness. If a delivery is lost, misordered or duplicated, the revisions feed still has the authoritative version.
Subscribing
curl -X POST https://staging-api.quendoo.com/v1/qc/subscriptions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-pms.example/webhooks/qc",
"event_types": ["booking.arrived", "booking.modified", "booking.cancelled"],
"property_ids": [4211]
}'
The response returns the subscription and the signing secret,
shown once. Store it right away — the endpoint never returns it
again. Lost? POST /subscriptions/{id}/rotate-secret mints a new
one; the old one keeps validating past deliveries for 24 h so an
in-flight batch does not fail.
Delivery shape
POST https://your-pms.example/webhooks/qc
Content-Type: application/json
User-Agent: Quendoo-Connect-Webhooks/1
X-Qc-Timestamp: 1735689600
X-Qc-Signature-256: sha256=6a5f2b…
X-Qc-Subscription: sub_1a2b3c
X-Qc-Delivery: dlv_9d8e7f
X-Qc-Event: booking.arrived
X-Qc-Attempt: 1
{
"id": "evt_a1b2c3",
"type": "booking.arrived",
"created_at": "2027-02-14T09:12:03Z",
"property_id": 4211,
"data": {
"booking_id": 161345,
"revision_id": "e5c1…",
"checkin_date": "2027-02-14",
"checkout_date": "2027-02-18"
}
}
Respond within 5 seconds with any 2xx. Anything else (non-2xx, timeout, TLS handshake failure) counts as a delivery failure and the retry ladder in the SLA kicks in.
Verifying a delivery (in order)
- Freshness — reject anything older than 5 minutes vs
X-Qc-Timestamp. This is what stops a stolen delivery from being replayed hours later. - Signature — recompute
HMAC-SHA256(secret, "<timestamp>.<raw body>")and compare it toX-Qc-Signature-256in constant time.<raw body>is the byte stream your web server received, not re-serialised JSON. Frameworks that parse JSON before your handler mint different bytes; grab the raw body. - Idempotency —
X-Qc-Deliveryis unique per attempt. Sub-second retries are common on network flakes; treat the same delivery id as a duplicate and don't reprocess the payload.X-Qc-Attemptstarts at 1 and increments on the retry ladder. - Scope — check
X-Qc-Subscriptionmatches a subscription you own. Multiple integrations on one server otherwise cross-wire.
Only after all four accept the delivery should your handler respond 2xx.
Node.js
import crypto from 'crypto';
import express from 'express';
const app = express();
// IMPORTANT: raw body, not the parsed JSON. Register the raw
// parser BEFORE express.json() for this route.
app.post('/webhooks/qc',
express.raw({ type: 'application/json' }),
(req, res) => {
const ts = req.header('X-Qc-Timestamp');
const sig = req.header('X-Qc-Signature-256')?.replace(/^sha256=/, '');
const body = req.body; // Buffer, raw bytes
if (!ts || !sig || !body) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(408); // stale
}
const expected = crypto
.createHmac('sha256', process.env.QC_SIGNING_SECRET)
.update(`${ts}.${body.toString('utf8')}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.sendStatus(401);
}
// TODO: idempotency check against X-Qc-Delivery, then process.
res.sendStatus(200);
}
);
Python
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = os.environ['QC_SIGNING_SECRET'].encode()
app = Flask(__name__)
@app.post('/webhooks/qc')
def qc_webhook():
ts = request.headers.get('X-Qc-Timestamp')
sig = (request.headers.get('X-Qc-Signature-256') or '').removeprefix('sha256=')
if not ts or not sig:
abort(400)
if abs(time.time() - int(ts)) > 300:
abort(408) # stale
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f'{ts}.{body.decode()}'.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
# TODO: idempotency, then process
return '', 200
PHP
$ts = $_SERVER['HTTP_X_QC_TIMESTAMP'] ?? '';
$sig = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_QC_SIGNATURE_256'] ?? '');
if ($ts === '' || $sig === '') { http_response_code(400); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(408); exit; }
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $ts.'.'.$body, getenv('QC_SIGNING_SECRET'));
if (! hash_equals($expected, $sig)) { http_response_code(401); exit; }
// TODO: idempotency, then process
http_response_code(200);
Bash / curl (test-vector reproduction)
TS=$(date -u +%s)
BODY=$(cat sample.json)
echo -n "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$QC_SIGNING_SECRET" | awk '{print $2}'
Handy for one-off replays against your handler with a matching
X-Qc-Timestamp / X-Qc-Signature-256.
Retry ladder
Failed delivery re-fires at 5 s → 10 s → 30 s → 5 m → 1 h × 20
(details in SLA). Every attempt carries the
same X-Qc-Delivery and an incrementing X-Qc-Attempt. A
subscription that returns non-2xx for more than 72 h in a row
is auto-disabled and surfaced in your dashboard — reset it by
POST /subscriptions/{id}/enable.
Event catalogue
Full list under
GET /v1/qc/event-catalog.
Highlights:
| Event | Fires when |
|---|---|
booking.arrived |
A new booking lands and passes payment/policy checks. |
booking.modified |
Any material change to an accepted booking — dates, guests, price, extras. |
booking.cancelled |
The booking is cancelled — hotelier, guest, or overbooking recovery. |
booking.assigned |
The hotelier assigned a physical room number in the dashboard. |
inventory.push.completed |
Your inventory push (writes bucket) finished — success + failed counts in data. |
rate_plan.updated |
A rate plan's material fields (sell type, occupancy rates) changed. |
payment_policy.updated / cancellation_policy.updated |
Policy body change; audit-only, does not retroactively re-apply. |
Health
You can hit
GET /v1/qc/health/webhooks
scoped to your token to see per-subscription delivery success rate,
last delivery attempt, and next scheduled retry. The dashboard
shows the same panel with a "redeliver" button for the last 90
days per subscription.
What we don't do
- No signed URL query params. Signatures ride headers only.
- No IP allow-list. Egress rotates across our fleet; sign the body, don't gate on source IP.
- No mTLS out. Your endpoint is authenticated by HTTPS, Quendoo is authenticated by HMAC in the signature.