Creating reservations
One endpoint books a stay wherever the listing's bookings live. A listing managed in a connected PMS is booked in that PMS; any other listing gets a direct booking. Quote it first, check what the listing supports, and change or cancel it through the same API.
Where the booking is made
| The listing is… | What happens |
|---|---|
| Managed in a connected PMS | The booking is created in the PMS first, then recorded in Repull from the PMS's own record, so the next sync lands on the same confirmation code and nothing is duplicated. The PMS checks availability and prices the stay. It is never created only in Repull — the PMS would keep selling the dates. |
| Not managed in a PMS | A direct booking in Repull, with the guest, the conversation, the calendar block and the reservation.created webhook. Priced from the listing's own rates. Availability is not checked — call GET /v1/availability/{propertyId} first if that matters. |
The PMSs that take bookings this way are Mews, Cloudbeds, Hostaway, Guesty, Beds24, BookingSync, Lodgify, Smoobu, Hospitable, iGMS, OwnerRez and Track. What each one can do differs, and anything a PMS cannot do is refused with a clear error rather than faked — see PMS reservation support.
Channel bookings are not created here
platform takes direct (the default), website or owner. Airbnb, Booking.com and Vrbo bookings belong to the channel: they arrive through sync, and a booking that came from a channel — including one relayed through a PMS — is changed or cancelled on the channel (409 reservation_owned_by_channel). On a PMS listing, owner is refused too: block owner stays in the PMS.Check what a listing supports
GET /v1/listings/{id} returns capabilities.reservations: which writes the API will perform for that listing. A flag is true only when the PMS supports it, the workspace is still connected to the PMS, and the connection's write policy allows bookings through the API. Read it before you build a booking form, and use notes to explain to your user why something is off. GET /v1/connect/{provider} returns the same object for a whole PMS connection.
/v1/listings/{id}curl "https://api.repull.dev/v1/listings/4118" \
-H "Authorization: Bearer sk_live_YOUR_KEY"
# "capabilities": {
# "reservations": {
# "managedBy": "pms",
# "provider": "hostaway",
# "create": true,
# "modify": true,
# "cancel": true,
# "quote": true,
# "customPrice": true,
# "notes": "Direct-channel bookings: create (at a set total, or priced by Hostaway's calculator), quote, …",
# "verifiedAgainst": "vendor_docs"
# }
# }managedBystring`pms` — booked in the connected PMS. `repull` — a direct booking.
providerstringnullableThe PMS, e.g. `hostaway`. Null for direct bookings.
createboolean`POST /v1/reservations` will book this listing.
modifyboolean`PATCH /v1/reservations/{id}` will change its bookings.
cancelboolean`POST /v1/reservations/{id}/cancel` will cancel its bookings.
quoteboolean`POST /v1/reservations/quote` will price it.
customPriceboolean`totalPrice` on create is honoured. Otherwise the PMS (or the listing's rates) prices the stay.
notesstringWhat the flags do not say: limits, the access the PMS needs, and why something is off.
verifiedAgainststringnullable`sandbox` (run end to end on the vendor's sandbox) or `vendor_docs` (verified against the vendor's API documentation). Null for direct bookings.
Quote first
POST /v1/reservations/quote asks the PMS for the price and availability of a stay without booking anything. It is the same check a create makes when you send no totalPrice, so available: true with a total is what that create would charge — though the dates can still be taken in between.
/v1/reservations/quotecurl -X POST "https://api.repull.dev/v1/reservations/quote" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "listingId": 4118, "checkIn": "2026-10-01", "checkOut": "2026-10-05", "adults": 2, "children": 1 }'listingIdintegerRequiredThe listing to quote.
checkIndateRequiredArrival, YYYY-MM-DD.
checkOutdateRequiredDeparture, YYYY-MM-DD, after checkIn.
adultsintegerAdults, 1 or more.
childrenintegerChildren, 0 or more.
guestCountintegerTotal guests, when you do not split adults and children.
unitIdstringQuote one unit (`GET /v1/listings/{id}` → `units[].id`).
Bookable
{
"listingId": "4118",
"provider": "hostaway",
"checkIn": "2026-10-01",
"checkOut": "2026-10-05",
"available": true,
"total": 880,
"currency": "USD",
"breakdown": { "accommodation": 720, "cleaningFee": 100, "taxes": 60 },
"restrictions": []
}Not bookable as asked
available: false is an answer, not an error. The PMS's reasons are in restrictions, in its own words.
{
"listingId": "4118",
"provider": "guesty",
"checkIn": "2026-10-01",
"checkOut": "2026-10-02",
"available": false,
"total": null,
"currency": null,
"breakdown": null,
"restrictions": ["Guesty: minimum stay is 3 nights"]
}- A listing not managed in a PMS answers 422 pms_not_linked. Price it with
GET /v1/quotesinstead. - Mews, Cloudbeds and iGMS have no quote API and answer 422 pms_write_unsupported. You can still book them; on iGMS a
totalPriceis required. - Hospitable quotes need an active Hospitable Direct plan.
Create the reservation
/v1/reservationscurl -X POST "https://api.repull.dev/v1/reservations" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2f7e-4a3b-4f2e-9c8d-1b6a0e5d7c31" \
-d '{
"listingId": 4118,
"checkIn": "2026-10-01",
"checkOut": "2026-10-05",
"guest": {
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"phone": "+14035551234"
},
"adults": 2,
"children": 1,
"totalPrice": 880,
"notes": "Late arrival, around 22:00.",
"status": "confirmed",
"sendConfirmationEmail": false
}'Request fields
listingIdintegerRequiredThe listing to book — see `GET /v1/properties`.
checkIndateRequiredArrival, YYYY-MM-DD.
checkOutdateRequiredDeparture, YYYY-MM-DD, after checkIn.
guestobjectRequired`firstName` (required), `lastName`, `email`, `phone` (E.164 preferred). Matched against existing guests on email, then phone, plus name, so repeat guests are not duplicated.
adultsintegerAdults, 1 or more.
childrenintegerChildren, 0 or more.
guestCountintegerTotal guests. On a PMS listing without `adults`, used as the adult count.
statusstringDefault: confirmed`confirmed` or `tentative` — an optional hold, where the PMS has one (iGMS refuses it).
platformstringDefault: direct`direct`, `website` or `owner`. `owner` is refused on a PMS listing.
totalPricenumberPMS listings only. The total for the whole stay in the listing's currency. Honoured where `capabilities.reservations.customPrice` is true; omit it and the PMS prices the stay. Required on iGMS.
notesstringPMS listings only. Booking notes stored in the PMS, up to 5000 characters.
unitIdstringPMS listings only. Book this unit (`GET /v1/listings/{id}` → `units[].id`). Needed on a multi-room Beds24 property; refused by Hostaway and iGMS.
sendConfirmationEmailbooleanPMS listings only. Ask the PMS to email the guest its own confirmation, where it supports that.
checkInTimestringDirect-booking listings only. HH:MM, e.g. 16:00.
checkOutTimestringDirect-booking listings only. HH:MM, e.g. 10:00.
currencystringDirect-booking listings only. ISO 4217 code; a PMS books in the property's currency.
guestIdintegerDirect-booking listings only. Attach an existing guest in this workspace instead of matching one.
Refused by name, never dropped
fields. A PMS listing uses the PMS's own arrival times, currency and guest records; a direct-booking listing is priced from its own rates, so it refuses totalPrice.Response — 201
{
"id": "215708",
"confirmationCode": "HA-4471923",
"listingId": "4118",
"platform": "direct",
"status": "confirmed",
"checkIn": "2026-10-01",
"checkOut": "2026-10-05",
"guestId": "91234",
"totalPrice": 880,
"currency": "USD",
"unit": null,
"pms": {
"provider": "hostaway",
"reservationId": "4471923",
"applied": ["reservation"],
"errors": [],
"partial": false,
"failedSections": []
}
}idstringThe Repull reservation id. Pass it to `GET`, `PATCH` and `/cancel`.
confirmationCodestringOn a PMS listing, the PMS's own code — the one the next sync carries.
totalPricenumbernullableWhat the booking was recorded at: your `totalPrice` where the PMS honours it, else the PMS's price (or, for a direct booking, the listing's rates).
unitobjectnullablePMS listings: the unit the PMS assigned, or null.
pmsobjectPresent when it was booked in a PMS.
A direct booking
On a listing no PMS manages, send the direct-booking fields instead. There is no pms block in the response.
curl -X POST "https://api.repull.dev/v1/reservations" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3b8e0c52-1d7a-4b1e-a2f9-6c0d8e7f1a24" \
-d '{
"listingId": 4119,
"checkIn": "2026-10-01",
"checkOut": "2026-10-05",
"guest": { "firstName": "Ada", "email": "ada@example.com" },
"guestCount": 2,
"checkInTime": "16:00",
"currency": "USD"
}'Idempotency: send a key, and keep it
Always send an Idempotency-Key — a UUID generated where you build the booking. A network timeout on a booking is exactly the case it exists for. The key is also sent to the PMSas the booking's reference, so a retry that reaches the PMS a second time finds the booking instead of making another one.
| Answer | Retry? |
|---|---|
201, or any final refusal | Stored for 24 hours. The same key replays it with Idempotency-Status: cached; the PMS is not called again. |
502 pms_error | Not stored. Retry with the SAME key — the PMS finds the booking if the first attempt went through. |
409 pms_duplicate | The PMS already holds a booking with this key. existing names it: look it up instead of creating it again. |
502 reservation_created_in_pms_only | Stored, unlike every other 5xx. Do NOT retry, and never with a new key. |
422 pms_rejected | Correct the request, then send it with a NEW key. |
403 connection_reauth_required | Not stored. Reconnect, then retry with the same key. |
When the booking exists only in the PMS
Rarely, the PMS takes the booking but its record cannot be read back in time. The answer is a 502 that is final: the booking exists in the PMS (existing names it), it arrives in Repull with the next sync, and reservation.created fires then.
{
"error": {
"code": "reservation_created_in_pms_only",
"message": "Created in Lodgify (LG-88231) but its record could not be read here. It will arrive with the next sync.",
"fix": "Do NOT retry. The booking exists in Lodgify (see `existing`) but could not be recorded in Repull yet; it arrives with the next sync and `reservation.created` fires then. Retrying with the same `Idempotency-Key` replays this answer.",
"docs_url": "https://repull.dev/docs/errors/reservation_created_in_pms_only",
"request_id": "req_01J5X7Y8Z9ABCDEF12345678",
"provider": "lodgify",
"existing": { "externalId": "88231", "confirmationCode": "LG-88231" },
"pms": { "applied": ["reservation"], "errors": [] },
"retryable": false
}
}Do not retry with a new key
retryable: false as final and wait for the webhook. See reservation_created_in_pms_only.Partial success
A booking is made in steps: the reservation itself, then follow-ups such as notes or a tentative state. When the reservation was created but a follow-up did not apply, the answer is still 201, with pms.partial: true and the steps in pms.failedSections. The booking exists — do not create it again. Fix the missing part in the PMS, or tell your user what did not apply.
{
"id": "215709",
"confirmationCode": "SM-1290031",
"listingId": "4120",
"status": "confirmed",
"totalPrice": 640,
"currency": "EUR",
"pms": {
"provider": "smoobu",
"reservationId": "1290031",
"applied": ["reservation"],
"errors": [{ "section": "notes", "code": "rejected", "message": "Smoobu: notice too long" }],
"partial": true,
"failedSections": [{ "section": "notes", "code": "rejected", "message": "Smoobu: notice too long" }]
}
}Changing and cancelling
Changes and cancellations follow the booking to where it lives. A stay managed in a PMS is changed in the PMS first, then Repull's copy is refreshed from the PMS's record — changing only Repull's copy would be undone by the next sync. Both endpoints return the PMS's outcome as pms.
/v1/reservations/{id}curl -X PATCH "https://api.repull.dev/v1/reservations/215708" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d2c9a10-8e4f-4c3b-9a71-2f6e0b8d4c55" \
-d '{ "checkIn": "2026-10-02", "checkOut": "2026-10-06", "guestCount": 3 }'- On a PMS stay,
checkIn,checkOutandguestCountare changed. Moving it to another listing and changing arrival or departure times are done in the PMS (422 pms_write_unsupported), as is anything the PMS's API cannot change — dates on Smoobu, for example. - The PMS checks availability: taken dates answer
409 pms_unavailable. - Several PMSs keep the booked total when the dates change (Hostaway, Beds24, BookingSync, iGMS, Hospitable); OwnerRez does not recalculate charges either. Adjust the price in the PMS if it should change.
- Guest details, pricing,
status,platformand notes are refused by name with422 unsupported_field. See Update a reservation.
/v1/reservations/{id}/cancelcurl -X POST "https://api.repull.dev/v1/reservations/215708/cancel" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7a3f1e92-0c5b-4d8e-b6a4-9e2d1c0f8b37" \
-d '{ "reason": "Guest changed plans" }'- A PMS booking is cancelled in the PMS, then read back. No cancellation fee is charged.
- Lodgify declines the booking rather than deleting it.
- OwnerRez's API cannot cancel:
422 pms_write_unsupported— cancel it in OwnerRez. - Direct, website and owner bookings are cancelled in Repull, releasing the nights.
- Cancelling a booking that is already cancelled returns
alreadyCancelled: true, not an error. - A channel booking — including one relayed through a PMS — answers
409 reservation_owned_by_channel. So does a change; group bookings in the PMS answer409 pms_group_booking.
See Cancel a reservation for channel bookings. However a booking is changed — here, in the PMS, or on a channel — reservation.updated and reservation.cancelled fire when the change reaches Repull.
Each PMS at a glance
| PMS | Create | Change | Cancel | Quote | totalPrice |
|---|---|---|---|---|---|
| Mews | ✓ | ✓ | ✓ | – | ✓ |
| Cloudbeds | ✓ | ✓ | ✓ | – | – (rate plan price) |
| Hostaway | ✓ | ✓ | ✓ | ✓ | ✓ |
| Guesty | ✓ | ✓ | ✓ (direct + Vrbo) | ✓ | ✓ |
| Beds24 | ✓ | ✓ | ✓ | ✓ | ✓ |
| BookingSync | ✓ | ✓ | ✓ | ✓ | ✓ |
| Lodgify | ✓ | ✓ | ✓ (declines) | ✓ | ✓ |
| Smoobu | ✓ | ✓ (no dates) | ✓ | ✓ | ✓ |
| Hospitable | ✓ | ✓ | ✓ | ✓ (Direct plan) | ✓ |
| iGMS | ✓ | ✓ | ✓ | – | ✓ (required) |
| OwnerRez | ✓ | ✓ | – | ✓ | – (property rates) |
| Track | ✓ | ✓ | ✓ | ✓ | ✓ (Channel Key: "Allow Custom Pricing") |
Constraints and the access each PMS needs are on PMS reservation support. Mews and Cloudbeds were run end to end on their vendors' sandboxes; every other PMS is verified against the vendor's API documentation.
Errors
Every error carries fix and docs_url, plus provider when a PMS is involved. Switch on code.
| Code | HTTP | Meaning |
|---|---|---|
pms_write_unsupported | 422 | The PMS’s API cannot do this. Do it in the PMS. |
unsupported_field | 422 | A field this listing cannot take, named in `fields`. |
pms_rejected | 422 | The PMS refused the content. Fix it; retry with a NEW key. |
pms_unavailable | 409 | Dates or unit taken in the PMS. Nothing booked. |
pms_duplicate | 409 | Already booked with this key. `existing` names it. |
pms_group_booking | 409 | Part of a group booking. Change it in the PMS. |
reservation_owned_by_channel | 409 | A channel booking. Change it on the channel. |
no_connection | 409 | The listing’s PMS is no longer connected. Reconnect. |
pms_writes_off | 409 | The connection’s write policy turns API bookings off. |
connection_reauth_required | 403 | The grant lacks booking write access. Reconnect. |
pms_not_linked | 422 | Quote only: the listing is not managed in a PMS. |
pms_error | 502 | The PMS could not be reached. Retry with the SAME key. |
reservation_created_in_pms_only | 502 | Booked in the PMS only. Do NOT retry. |
API reference
Create a reservation · Quote a reservation · Update · Cancel · Idempotency