Real-time events for your hotel stack

Subscribe once, receive a JSON payload the moment something changes. HMAC-signed, automatically retried, and replayable for the past 72 hours. Everything you need to keep your downstream systems perfectly in sync.

12

Events

HMAC-SHA256

Signing

8 attempts

Retries

72h window

TTL

Every event looks the same

A stable envelope wraps every payload. Top-level fields tell you what happened; data.object holds the full resource at the moment the event was created.

idUnique event identifier. Use for idempotency.
typeEvent type (e.g. reservation.created).
created_atISO 8601 timestamp in UTC.
api_versionSchema version this payload follows.
livemodetrue for production, false for sandbox.
property_idThe property the event belongs to.
data.objectThe full resource at time of event.
reservation.created payload
{
  "id": "evt_7d2a9c3f81b4",
  "type": "reservation.created",
  "created_at": "2026-04-13T14:22:31Z",
  "api_version": "2026-01-15",
  "livemode": true,
  "property_id": "prop_8hJk3m",
  "data": {
    "object": {
      "id": "res_9kLm2p",
      "status": "confirmed",
      "check_in": "2026-05-10",
      "check_out": "2026-05-14",
      "nights": 4,
      "adults": 2,
      "children": 0,
      "room_type_id": "rt_standard",
      "rate_plan_id": "rp_bar",
      "total": { "amount": 86000, "currency": "USD" },
      "guest": {
        "id": "gst_4fPq2x",
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "ada@example.com"
      },
      "source": "direct_booking",
      "channel": null,
      "created_at": "2026-04-13T14:22:30Z"
    }
  }
}

12 event types — subscribe to just what you need

Per-endpoint filtering. You can register multiple endpoints and give each one a different event selector. No noisy firehose unless you ask for one.

EventDescription
reservation.createdA new reservation was created in SwiftGuest or arrived via a channel
reservation.updatedReservation dates, rooms, rates, or metadata were modified
reservation.cancelledReservation was cancelled by staff, guest, or channel
reservation.checked_inGuest has been marked as checked in
reservation.checked_outGuest checkout completed, folio closed
payment.succeededA charge was completed against a reservation or folio
payment.failedPayment attempt failed — card declined, insufficient funds, etc.
payment.refundedA refund was issued — full or partial
guest.createdA new guest profile was created
housekeeping.status_changedRoom status changed — dirty, clean, inspected, out-of-order
rate.updatedA rate plan or daily rate was modified
inventory.updatedRoom availability changed for a given date range

HMAC-SHA256 on every delivery

Each request includes a SwiftGuest-Signature header with a timestamp and HMAC. Sign the raw body (not a parsed JSON object) with your endpoint's signing secret and compare with a constant-time function. Reject stale timestamps.

Header format

SwiftGuest-Signature: t=1744572300,v1=9a2e4b1c7f3d...

t = unix timestamp in seconds. v1 = hex-encoded HMAC-SHA256 of <t>.<raw_body>.

Node.js / TypeScript

verify.ts
import crypto from "node:crypto";

export function verifySwiftGuestSignature(
  rawBody: string,
  headerValue: string,
  secret: string,
): boolean {
  // Header looks like: "t=1744572300,v1=9a2e4b..."
  const parts = Object.fromEntries(
    headerValue.split(",").map((kv) => kv.split("=") as [string, string]),
  );

  const timestamp = parts.t;
  const signature = parts.v1;

  if (!timestamp || !signature) return false;

  // Reject anything older than 5 minutes to prevent replay
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (age > 300) return false;

  const signedPayload = `${timestamp}.${rawBody}`;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Use timingSafeEqual to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex"),
  );
}

// Express handler
app.post(
  "/webhooks/swiftguest",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const valid = verifySwiftGuestSignature(
      req.body.toString("utf8"),
      req.header("SwiftGuest-Signature") ?? "",
      process.env.SWIFTGUEST_WEBHOOK_SECRET!,
    );

    if (!valid) return res.status(401).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // Enqueue and ack immediately — never do heavy work in the handler
    queue.enqueue(event);

    res.status(200).send("ok");
  },
);

Python

verify.py
import hmac
import hashlib
import time


def verify_swiftguest_signature(
    raw_body: bytes,
    header_value: str,
    secret: str,
) -> bool:
    # Header: "t=1744572300,v1=9a2e4b..."
    parts = dict(item.split("=", 1) for item in header_value.split(","))
    timestamp = parts.get("t")
    signature = parts.get("v1")

    if not timestamp or not signature:
        return False

    # Reject if older than 5 minutes
    if int(time.time()) - int(timestamp) > 300:
        return False

    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)


# Flask handler
@app.post("/webhooks/swiftguest")
def handle_webhook():
    raw = request.get_data()
    header = request.headers.get("SwiftGuest-Signature", "")

    if not verify_swiftguest_signature(raw, header, SECRET):
        abort(401)

    event = request.get_json()
    enqueue(event)  # Ack quickly, do the work async
    return "", 200

Sign the raw body, not a parsed object

If your framework deserializes JSON before the handler runs, key ordering and whitespace can change, breaking the signature. Use middleware that preserves the raw buffer (e.g. express.raw, bodyParser.raw, FastAPI's Request.body()).

Exponential backoff, 72-hour window

Any non-2xx response triggers a retry. We back off exponentially for up to 8 attempts across roughly 72 hours. After the final attempt, the delivery is marked permanently failed — you can still replay it manually.

  • 2xx — delivery succeeded, no retry
  • 3xx — redirects are not followed
  • 408, 429, 5xx — automatic retry
  • Other 4xx — permanent failure, no retry
AttemptDelay after previousElapsed
10s0s
230s30s
32m2m 30s
410m12m 30s
51h1h 12m
66h7h 12m
724h~31h
872h~103h

Circuit breaker

If an endpoint returns 5xx for more than 95% of deliveries over a 5-minute window, we temporarily disable it and email the developer contact. Re-enable with a single click once you've deployed a fix.

Design for at-least-once delivery

Network glitches happen. A 2xx response can be lost between your server and ours, triggering a retry of an event you already processed. Treat every webhook as if it might arrive twice.

  • Store event.id in a uniquely-indexed table
  • Dedupe inside the same DB transaction as your business logic
  • Prefer upserts over inserts for row-level operations
  • Keep event records for at least 30 days for debugging
idempotent handler
// Every webhook delivery has a unique id. Store it.
const KNOWN = new Set<string>(); // Use Redis / Postgres in production

export async function handleEvent(event: WebhookEvent) {
  if (KNOWN.has(event.id)) {
    // We already processed this exact event — ack and move on
    return;
  }

  await db.transaction(async (tx) => {
    await tx.insert("webhook_events", { id: event.id });
    await applyBusinessLogic(tx, event);
  });

  KNOWN.add(event.id);
}

Replay any event from the last 72 hours

Missed a delivery because your server was down? Shipped a bug and need to reprocess a window of events? Use the dashboard replay UI or call the replay API. Single events and bulk backfills are both supported.

replay & backfill
# Replay a single event from the dashboard or via API

curl -X POST https://api.swiftguest.com/v1/webhook_endpoints/whe_abc/replay \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "evt_7d2a9c3f81b4" }'

# Response
{
  "replayed": true,
  "event_id": "evt_7d2a9c3f81b4",
  "delivery_id": "whd_new_9m2k",
  "status": "queued"
}

# Replay a full 24-hour window (e.g. after fixing a bug)
curl -X POST https://api.swiftguest.com/v1/webhook_endpoints/whe_abc/backfill \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "since": "2026-04-12T00:00:00Z",
    "until": "2026-04-13T00:00:00Z",
    "event_types": ["reservation.created", "payment.succeeded"]
  }'

Delivery log

Every attempt is stored with response code, body, latency, and headers. Inspect any delivery from the dashboard.

Bulk replay

Backfill a time window and event-type filter. Events replay in original order, throttled to respect your rate limit.

Replayed event ids

event.id stays the same on replay — your idempotency layer will correctly dedupe.

Ship a webhook handler that won't wake you up

Four rules we've watched save integrations from production incidents.

Respond in under 5 seconds

Acknowledge with a 2xx status immediately, then push the work to a queue. Slow endpoints trigger retries and throttling.

Never skip signature verification

Verify every request with timingSafeEqual. Reject unsigned requests, mismatched signatures, and timestamps older than 5 minutes.

Embrace idempotency

Retries are a feature, not a bug. Store event.id and dedupe before applying side effects to your database.

Log deliveries with correlation IDs

Log the SwiftGuest-Request-Id header and event.id on every request. Makes support conversations painless.

Tunnel from your laptop in one command

Use the SwiftGuest CLI to receive live events on localhost. Register a tunnel URL, trigger test events from the dashboard, and iterate on your handler without deploying.

swiftguest-cli
# Install
npm install -g @swiftguest/cli

# Authenticate with a personal access token
swiftguest login

# Forward events to a local port
swiftguest listen --forward http://localhost:3000/webhooks

# Send a sample event from the dashboard, or trigger one here
swiftguest events trigger reservation.created \
  --property prop_8hJk3m \
  --guest-email test@example.com

Stuck on an event payload?

Open a GitHub issue with an example delivery id. We'll pull the delivery record, walk through the signature, and ship docs if anything is unclear.