# Lodgehog PMS API - full reference Read-only REST API for a hotel property management system. An API key belongs to exactly one property and can only ever read that property's data. The API has no write operations. Base URL: https://api.lodgehog.com/v1 OpenAPI schema: https://api.lodgehog.com/v1/schema/ (add ?format=json for JSON) Interactive reference: https://api.lodgehog.com/v1/docs/ ## Authentication Send the key as a bearer token on every request: curl https://api.lodgehog.com/v1/reservations/ \ -H "Authorization: Bearer lh_live_..." Keys start with `lh_live_`. A property administrator creates them under Property settings > API & Webhooks in the PMS. The key is shown once at creation; only a hash is stored, so a lost key has to be replaced rather than recovered. Keys can be revoked, which takes effect immediately. There is no OAuth flow, no refresh token and no expiry. ## Conventions - All timestamps are UTC ISO 8601 ending in `Z`, e.g. `2026-09-22T12:00:00Z`. - Dates (check-in, check-out, charge dates) are plain `YYYY-MM-DD`. - Money is a decimal **string** with two places, e.g. `"850.00"`. Never a JSON number: a float loses cents. - Currency is the property's, given as an ISO 4217 code in `totals.currency`. - Ids are UUIDs. - Enumerated values are readable words (`confirmed`, `pix`, `per_night`), not internal codes. ## Endpoints ### GET /v1/reservations/ The property's reservations, newest first. Paginated (cursor). Query parameters: - `status` - comma-separated. One or more of: `confirmed`, `checked_in`, `checked_out`, `prereserved`, `cancelled`, `blocked`, `pending_payment`. - `checkin_from`, `checkin_to` - `YYYY-MM-DD`, inclusive. - `checkout_from`, `checkout_to` - `YYYY-MM-DD`, inclusive. - `modified_since` - ISO 8601 timestamp. Reservations changed at or after it. - `created_since` - ISO 8601 timestamp. - `guest` - guest id. Matches the main guest or any additional guest. - `room` - room id. - `include_cancelled` - `true` to include cancelled reservations. - `limit` - page size, default 50, maximum 200. - `cursor` - opaque; take it from the `next` link, do not construct it. Cancelled reservations are **excluded by default**. They are included if `include_cancelled=true`, or if `status` names `cancelled`. ### GET /v1/reservations/{id}/ One reservation, in the same shape as a list row. Cancelled reservations are returned here regardless of any filter. ### GET /v1/guests/ The property's guests, ordered by name. Paginated (cursor). Parameters: `q` (matches name or e-mail), `email`, `identity_number`, `limit`, `cursor`. ### GET /v1/guests/{id}/ ### GET /v1/rooms/ Every room, unpaginated - a property has tens of them, and an integration resolving the ids on a reservation wants them all at once. Parameter: `room_category` (id). ### GET /v1/room-categories/ Every room category, unpaginated. ## Pagination List responses look like: {"next": "", "previous": "", "results": [...]} Follow `next` until it is `null`. The cursor is keyed on creation time, so rows created while you page do not shift the ones you have not read yet - which page numbers would. `rooms` and `room-categories` are not paginated and return a bare JSON array. ## The reservation object A reservation comes back whole. Rooms, guests, charges, payments and computed totals are embedded, so a list response needs no follow-up calls. { "id": "e38b863a-6d4f-48a2-bb42-fee423076cbb", "booking_code": "845D400246", "status": "confirmed", "source": { "channel_code": "BDC", "channel_name": "Booking.com", "made_by": "channel_manager" }, "checkin_date": "2026-10-10", "checkout_date": "2026-10-11", "nights": 1, "adults": 2, "children": 1, "notes": null, "guest": { "id": "...", "name": "Ana Beatriz Souza", "email": null, "phone": null, "date_of_birth": null, "nationality": null, "identity_type": null, "identity_number": null, "address": null, "company": null, "notes": null }, "additional_guests": [], "rooms": [ { "room": {"id": "...", "name": "302"}, "room_category": {"id": "...", "name": "Piscina Privativa"}, "rate_plan": {"id": "...", "name": "Tarifa Flexivel"}, "price": "850.00", "price_type": "per_night", "adults": 2, "children": 1, "child_ages": [] } ], "charges": [ {"id": "...", "name": "Minibar", "date": "2026-10-10", "unit_value": "25.00", "quantity": 2, "total": "50.00", "item_id": null, "created_by_channel_manager": false} ], "payments": [ {"id": "...", "method": "pix", "value": "850.00", "date": "2026-10-10", "account": {"id": "...", "name": "Main account"}, "billing_id": null, "stripe_payment_intent_id": null, "created_by_channel_manager": false} ], "billing": null, "deposit": null, "totals": { "currency": "BRL", "total_price": "850.00", "total_charges": "50.00", "total_payments": "850.00", "total_due": "50.00", "payment_status": "partial" }, "created_at": "2026-09-23T13:58:05.349707Z", "modified_at": "2026-09-21T13:58:05.348646Z", "version": 1789998138642209 } Field notes: - `booking_code` is what the property's staff see and quote. It is not the id. - `status`: `confirmed`, `checked_in`, `checked_out`, `prereserved`, `cancelled`, `blocked`, `pending_payment`. A cancelled reservation keeps its row; it is never deleted. - `blocked` is not a guest stay - it is the property holding a room out of inventory. `pending_payment` is a booking-engine reservation awaiting payment, which expires on its own if unpaid. - `source.made_by`: `pms`, `booking_engine`, `chatbot` or `channel_manager`. - `rooms` lists what was sold. A whole-house booking carries extra internal rows for the bedrooms inside it; those are not here, because nobody chose or paid for them. - `price_type` is `per_night` or `total`, and says how to read `price`. - `charges` are folio extras (minibar, breakfast), not the room rate. - `payments` is money actually received. - `billing` is set when the stay is billed to a company. That is a receivable, not money received, which is why it is not a payment. - `totals.total_due` = `total_price` + `total_charges` - `total_payments`. - `totals.payment_status`: `paid`, `partial`, `unpaid` or `billed`. - `version` increments when the reservation itself is saved. A change to its charges, payments, rooms or guests does not move it, but does move `modified_at` -- compare `modified_at` to tell whether you have seen a change. ## Keeping in sync Store the timestamp of your last successful poll. Ask for `modified_since=`, walk `next` to the end, then store the new timestamp. This catches edits and cancellations as well as new bookings, which polling `created_since` alone would miss -- including a charge, payment, room or guest change, which moves the reservation's `modified_at`. For near-real-time, subscribe to webhooks and use polling as a backstop. ## Errors Every error has the same shape: {"error": {"code": "invalid_api_key", "message": "Invalid API key."}} - `400` - a bad filter value. `error.details` names the parameter. - `401` - the key is missing, malformed, unknown or revoked. - `402` - the key is valid but the property's plan does not include API access. The property has to upgrade; retrying will not help. - `404` - no such record **for this property**. A reservation belonging to a different property is a 404, not a 403. - `429` - rate limited. See below. ## Rate limits Each key has a per-minute ceiling, shown next to it in the PMS (120 by default). Every response carries: - `X-RateLimit-Limit` - the ceiling. - `X-RateLimit-Remaining` - what is left in the current minute. - `X-RateLimit-Reset` - Unix epoch seconds when the window resets. Over the limit you get `429` with a `Retry-After` header in seconds. Wait that long rather than retrying immediately. If an integration needs a higher ceiling, the property can ask support to raise it. ## Webhooks A property can register endpoint URLs under Property settings > API & Webhooks. We POST to them when a reservation changes. Events: `reservation.created`, `reservation.updated`, `reservation.deleted` (a cancellation), and `test.ping` (sent on demand from the settings page). Anything that changes what the API would return for a reservation - a charge posted, a payment taken, a room swapped, a guest added - is a `reservation.updated` for that reservation. Several such changes made together produce one webhook, not one per change. ### Envelope { "id": "756e4f4b-6656-42df-a417-ee32cb6be9af", "event": "reservation.updated", "api_version": "v1", "created_at": "2026-09-22T12:16:55Z", "hotel_id": "63b0d3e8-923b-445a-8d45-00083b81e757", "data": { "reservation_id": "4c1f0a52-...", "reservation": { ...the same object the API returns... } } } `data.reservation` is null when the reservation no longer exists; `data.reservation_id` is always set, so you can still tell which one it was. For `test.ping`, `data` is `{}`. The body is built when the delivery is sent, not when the change happened, so a retry that lands an hour later carries current data. ### Headers - `X-Lodgehog-Signature` - `t=,v1=` - `X-Lodgehog-Event` - the event name - `X-Lodgehog-Delivery-Id` - matches `id` in the body - `X-Lodgehog-Attempt` - 1 on the first try ### Responding Answer with any `2xx`. Anything else - or a timeout over 10 seconds - is retried with growing backoff for about an hour (8 attempts). An endpoint whose deliveries keep failing is switched off, and the property is told. Deliveries can arrive more than once, so treat `X-Lodgehog-Delivery-Id` as an idempotency key. They can also arrive out of order: trust the reservation's `modified_at`, not the order of arrival. ### Verifying the signature The HMAC is SHA-256 over `"."`, keyed with the endpoint's signing secret. Verify against the raw bytes you received, before parsing, and reject anything older than five minutes. import hashlib, hmac, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) expected = hmac.new( secret.encode(), f'{parts["t"]}.'.encode() + raw_body, hashlib.sha256 ).hexdigest() fresh = abs(time.time() - int(parts["t"])) < 300 return fresh and hmac.compare_digest(expected, parts["v1"]) Requests are never redirected: we do not follow redirects, so the URL you register is the URL that receives the signed body.