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.{
"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.
| Event | Description |
|---|---|
reservation.created | A new reservation was created in SwiftGuest or arrived via a channel |
reservation.updated | Reservation dates, rooms, rates, or metadata were modified |
reservation.cancelled | Reservation was cancelled by staff, guest, or channel |
reservation.checked_in | Guest has been marked as checked in |
reservation.checked_out | Guest checkout completed, folio closed |
payment.succeeded | A charge was completed against a reservation or folio |
payment.failed | Payment attempt failed — card declined, insufficient funds, etc. |
payment.refunded | A refund was issued — full or partial |
guest.created | A new guest profile was created |
housekeeping.status_changed | Room status changed — dirty, clean, inspected, out-of-order |
rate.updated | A rate plan or daily rate was modified |
inventory.updated | Room 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
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
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 "", 200Sign 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
| Attempt | Delay after previous | Elapsed |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 1h | 1h 12m |
| 6 | 6h | 7h 12m |
| 7 | 24h | ~31h |
| 8 | 72h | ~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
// 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 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.
# 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.comStuck 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.