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)

  1. Freshness — reject anything older than 5 minutes vs X-Qc-Timestamp. This is what stops a stolen delivery from being replayed hours later.
  2. Signature — recompute HMAC-SHA256(secret, "<timestamp>.<raw body>") and compare it to X-Qc-Signature-256 in 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.
  3. Idempotency — X-Qc-Delivery is 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-Attempt starts at 1 and increments on the retry ladder.
  4. Scope — check X-Qc-Subscription matches 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