PMS integration
server-to-server
One read endpoint to load the member-on-reservation state. Five HMAC-signed webhooks to stay in sync with balance changes. No polling.
GET /reservation-snapshot
Returns everything you need to render the in-stay / checkout cash credit UI in a single round trip.
GET /api/v1/loyalty/reservation-snapshot?reservation_id=${reservationId}
Authorization: Bearer ${GUESTMAKER_API_KEY}
Accept: application/json
// Response (200)
{
"data": {
"member": {
"id": "uuid",
"member_number": "ABC123",
"email": "guest@example.com",
"tier": "silver",
"points_balance": 12500,
"spendable_points": 9500 // = points_balance - active holds
},
"active_holds": [
{
"hold_id": "uuid",
"external_reference_id": "BOOK-2026-001",
"points": 3000,
"amount_value": 30.00,
"currency": "EUR",
"surface": "booking_direct",
"status": "active",
"expires_at": "2026-06-01T12:30:00Z"
}
],
"config": {
"enabled": true,
"surfaces": { "booking_direct": true, "in_stay": true, "checkout": false },
"allowed_channels": ["direct", "website", "phone"],
"redemption_rate_eur": 100, // points per EUR 1
"min_points_per_redemption": 500,
"hold_minutes": 10
}
}
}Use spendable_points — neverpoints_balance— when deciding what the member can apply right now. Active holds are already subtracted (guardrail #631).
Webhook events
Five events. All HMAC-signed. Retried with exponential backoff (1m, 5m, 30m, 4h, 24h).
loyalty.credit.held{
"event": "loyalty.credit.held",
"tenant_id": "uuid",
"occurred_at": "2026-06-01T10:00:00Z",
"data": {
"hold_id": "uuid",
"member_id": "uuid",
"points": 3000,
"amount_value": 30.00,
"currency": "EUR",
"surface": "booking_direct",
"external_reference_id": "BOOK-2026-001",
"expires_at": "2026-06-01T10:10:00Z"
}
}loyalty.credit.confirmed{
"event": "loyalty.credit.confirmed",
"tenant_id": "uuid",
"occurred_at": "2026-06-01T10:05:00Z",
"data": {
"hold_id": "uuid",
"transaction_id": "uuid",
"member_id": "uuid",
"points": 3000,
"amount_value": 30.00,
"currency": "EUR",
"external_reference_id": "BOOK-2026-001"
}
}loyalty.credit.released{
"event": "loyalty.credit.released",
"tenant_id": "uuid",
"occurred_at": "2026-06-01T10:11:00Z",
"data": {
"hold_id": "uuid",
"external_reference_id": "BOOK-2026-001",
"cause": "expired" // "released" if called via /credit/release
}
}loyalty.credit.reversed{
"event": "loyalty.credit.reversed",
"tenant_id": "uuid",
"occurred_at": "2026-06-02T09:00:00Z",
"data": {
"hold_id": "uuid",
"refund_transaction_id": "uuid",
"member_id": "uuid",
"points": 3000,
"external_reference_id": "BOOK-2026-001",
"cause": "cancellation"
}
}loyalty.balance.changed{
"event": "loyalty.balance.changed",
"tenant_id": "uuid",
"occurred_at": "2026-06-01T10:05:00Z",
"data": {
"member_id": "uuid",
"new_balance": 9500
}
}HMAC signature verification
Verify
X-Loyalty-Signature on every inbound webhook. Reject unsigned or mismatched payloads.// Node.js — verify the X-Loyalty-Signature header on every webhook.
import crypto from 'node:crypto';
function verifyLoyaltyWebhook(rawBody: string, signature: string, secret: string) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
// Constant-time compare to avoid timing side channels.
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express handler
app.post('/webhooks/loyalty', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Loyalty-Signature') || '';
if (!verifyLoyaltyWebhook(req.body.toString('utf8'), signature, process.env.LOYALTY_WEBHOOK_SECRET!)) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString('utf8'));
// ... dispatch on payload.event
res.status(200).end();
});The signing secret is generated when you register the webhook in Settings → Loyalty → Cash credit. Rotate by adding a new webhook URL, verifying with both secrets in parallel, then removing the old one.