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.