Channel managers and OTAs
A channel manager, online travel agency (OTA) or booking portal does three things with Grondia. It reads what a campground has free and at what rate. It pushes the reservations it sells. And it keeps both sides in step when something changes, whether the change came from your channel, from the campground’s own booking page or from its reception desk.
This page walks through each step with the calls, the refusals to expect and a checklist for going live. It assumes you have read Core concepts and made the first call in the Quickstart. Examples use the made-up Camping Beispielsee, where Anna Müller books pitch A12.
What the campground gets
Section titled “What the campground gets”- Its pitches on your channel, with live numbers. The counts in the feed come from the same availability check the campground’s own booking page uses, so the feed does not show a pitch as free that a booking would then find taken. (The feed can be a few minutes old, see below.)
- No double bookings. Your booking goes through the same overlap protection
as a booking typed in at reception. If the pitch is gone, the call answers
409and nothing is created. - Your bookings in its normal booking list. A booking you create carries
your
channellabel, which the campground sees in its bookings list and in the bookings CSV export. - No per-campground set-up for the feed beyond an API key.
Settle two things before you write code
Section titled “Settle two things before you write code”- The arrangement for payment and confirmation. See the note above. If your channel collects the guest’s money, tell the campground: there is no call to record that payment in Grondia, so the campground has to know.
- A key with the right scopes. A campground’s administrator creates keys
in the Grondia admin, but the admin cannot create the scopes a channel
manager needs:
ari:read,ari:writeandwebhooks:write. Write to Felix, felix@grondia.com, or to Siro, siro@grondia.com, say which campground and which scopes, and we set the key up. The campground needs the API module as well (Credentials).
The flow
Section titled “The flow”Every few minutes
Your channel manager to Grondia API:
GET ari/. Availability, rates, restrictionsGrondia API to Your channel manager: Categories with one row per night
Your channel manager: A guest books on your channel
Your channel manager to Grondia API:
POST bookings/. Withexternal_referenceGrondia API to Your channel manager: 201 pending booking, or 409 or 422
Grondia API to Your webhook endpoint:
booking.createdLater the booking is paid or confirmed
Grondia API to Your webhook endpoint:
booking.confirmedYour channel manager: The guest cancels on your channel
Your channel manager to Grondia API:
POST bookings/{id}/ cancel/ Grondia API to Your channel manager: 200 cancelled, with the refund
Grondia API to Your webhook endpoint:
booking.cancelledEvery few minutes
Your channel manager to Grondia API:
GET bookings/. Withupdated_sinceGrondia API to Your channel manager: Every booking changed since the last sweep
Webhooks tell you quickly that something changed. The sweep at the bottom catches a webhook that never arrived, a change made at reception and the campground’s own bookings. Neither of them sees a booking the campground deletes (Syncing changes).
Scopes
Section titled “Scopes”Paths are relative to https://api.campone.ch/api/v1/public/. The full table
is in Credentials.
| Scope | What a channel manager uses it for |
|---|---|
ari:read |
GET ari/ and GET ari/restrictions/. A key with sites:read is accepted for both reads too, so the sandbox key can already call them. |
ari:write |
PUT ari/restrictions/, only if you set stop-sell or minimum stays yourself. |
bookings:write |
POST bookings/ and POST bookings/{id}/cancel/. |
bookings:read |
GET bookings/ for the sweep, and GET bookings/{id}/. |
webhooks:write |
Register a webhook and read its delivery log. |
Ask for what you use and no more: the operator sees every scope on the key.
Guest names and email come back redacted on every read, because
bookings:pii cannot be granted yet (see
Personal data). Keep your own copy of
what the guest gave you.
Dates and time zones
Section titled “Dates and time zones”- Every stay date is a calendar date,
YYYY-MM-DD, in the campground’s own calendar, not UTC. The feed’s defaultstartis today in the campground’s time zone. For a Swiss campground that isEurope/Zurich. - A feed row is a night. The row dated
2027-07-14prices the night that starts on that day. A stay from2027-07-14to2027-07-17uses the rows of the 14th, 15th and 16th. - Timestamps in answers are ISO 8601 in UTC, such as
updated_at.updated_sinceaccepts an ISO date or datetime. A value without an offset is read in the campground’s time zone, so send aZor an offset to say what you mean. Dates, times and money has the rest.
Read availability and rates
Section titled “Read availability and rates”The ARI feed (availability, rates and inventory) returns every category the
campground sells, with a row per night, in one call. A category is a site
type, such as “Standard pitch” or “Cabin”, and code is its stable key.
curl "https://api.campone.ch/api/v1/public/ari/?start=2027-07-14&end=2027-07-18" \ -H "Authorization: Bearer $GRONDIA_API_KEY"import os
import requests
response = requests.get( "https://api.campone.ch/api/v1/public/ari/", params={"start": "2027-07-14", "end": "2027-07-18"}, headers={"Authorization": f"Bearer {os.environ['GRONDIA_API_KEY']}"}, timeout=10,)response.raise_for_status()feed = response.json()
for category in feed["categories"]: for day in category["days"]: print(category["code"], day["date"], day["available"], day["price"])const url = new URL("https://api.campone.ch/api/v1/public/ari/");url.searchParams.set("start", "2027-07-14");url.searchParams.set("end", "2027-07-18");
const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.GRONDIA_API_KEY}` },});if (!response.ok) throw new Error(`HTTP ${response.status}`);const feed = await response.json();
for (const category of feed.categories) { for (const day of category.days) { console.log(category.code, day.date, day.available, day.price); }}The answer, shortened to one category:
{ "tenant": "beispielsee", "currency": "CHF", "start": "2027-07-14", "end": "2027-07-18", "categories": [ { "code": "standard", "name": "Standard pitch", "online_bookable": true, "max_occupancy": 6, "capacity_total": 18, "restrictions": { "min_nights": 2, "max_nights": 0, "booking_cutoff_hour": 18, "max_booking_date": null }, "days": [ { "date": "2027-07-14", "available": 4, "price": "42.00", "closed": false, "min_stay": 2, "closed_to_arrival": false, "closed_to_departure": false }, { "date": "2027-07-15", "available": 1, "price": "42.00", "closed": false, "min_stay": 2, "closed_to_arrival": false, "closed_to_departure": false }, { "date": "2027-07-16", "available": 2, "price": "42.00", "closed": false, "min_stay": 2, "closed_to_arrival": false, "closed_to_departure": false }, { "date": "2027-07-17", "available": 0, "price": "42.00", "closed": true, "min_stay": 2, "closed_to_arrival": true, "closed_to_departure": false } ] } ]}The stay used in the rest of this page, 14 to 17 July, takes the nights of the
14th, 15th and 16th, and all three are open. The 17th is sold out and closed to
arrival, which does not touch a guest who leaves that morning: for a departure
you read closed_to_departure on that row, and it is false.
The parameters, all optional:
| Parameter | Meaning |
|---|---|
start |
First night, inclusive. Default: today. A date in the past is moved to today. |
end |
Last night exclusive (on the restrictions call it is inclusive, see Date windows in the ARI feed). Default: start plus 90 days. A window longer than 370 days is cut to 370 days, not refused. An end that is not after start answers 400. |
site_type_code |
Only this category. |
The server keeps each answer for five minutes per campground, window and category, so asking more often returns the same numbers. A pull every five minutes is enough: at most 12 calls an hour for one window, against a default of 1000 requests an hour for each key (Credentials).
How to read a row
Section titled “How to read a row”| Field | What it means |
|---|---|
available |
Free pitches in this category for this night, by the same check that decides whether a booking is accepted. Pitches held back for a guest the campground still has to place after an overbooking are already subtracted. |
price |
The nightly pitch rate for this category and night. A seasonal price that covers the night wins over the category’s base price. When pitches in one category have different base prices, the feed shows the lowest. One rate per category: there are no rate plans. |
closed |
true when you cannot book this night: nothing is free, the night is past the campground’s last bookable date (restrictions.max_booking_date), or the campground has set a stop-sell. |
min_stay |
The minimum number of nights the campground has set for a stay that arrives on this date in this category: the category’s own seasonal minimum if it has one, otherwise the flat campground minimum, raised by any per-date minimum. It does not include a campground-wide seasonal or public-holiday minimum, which can be stricter. The create call applies seasonal minimums only for a campground that has switched them on (or that uses Grondia’s newer pricing), and then refuses a shorter stay with 400. The code is season_min_nights_violation when the seasonal minimum is the rule that refuses and min_nights_violation when the flat minimum is. |
closed_to_arrival |
true when no stay may arrive on this date. |
closed_to_departure |
true when no stay may leave on this date. Read it on the check-out date, so ask for a window that reaches one day past the last departure you care about. |
restrictions |
Rules for the whole category. min_nights is the campground-wide minimum, the same in every category. max_nights is the strictest maximum for the category, 0 for none. booking_cutoff_hour is the local hour after which a stay arriving tomorrow can no longer be booked online, 0 for none. max_booking_date is the last arrival date sold online, null for no limit. |
online_bookable |
Whether the campground sells this category online. Do not offer a category where it is false. |
The feed counts only pitches that are active and open to the public. Annual
pitches (Jahresplätze, rented for a whole season, see
Dauergast) and pitches the campground keeps for
reception are not in the numbers. currency is the campground’s, and so is the
currency of every amount in a booking
(Which currency).
Minimum stays differ per category
Section titled “Minimum stays differ per category”A campground can set a minimum stay per category and season, and a category’s
minimum may be lower than the campground-wide one. A cabin that rents from
two nights beside pitches that need five is a real case. Read min_stay on the
row of the arrival date for the category you are selling, never
restrictions.min_nights, which is only the campground-wide floor:
{ "categories": [ { "code": "standard", "restrictions": { "min_nights": 5 }, "days": [{ "date": "2027-10-01", "min_stay": 5 }] }, { "code": "cabin", "restrictions": { "min_nights": 5 }, "days": [{ "date": "2027-10-01", "min_stay": 2 }] } ]}(This excerpt keeps only the fields that matter; a real row has all seven.)
Resolve the minimum per category and per arrival date, and do not store one
number per campground. Treat min_stay as the lowest number of nights that can
work: the create call has the final word (next section).
The feed guides, the booking call decides
Section titled “The feed guides, the booking call decides”The feed can be up to five minutes old, and it does not carry every rule the
booking call applies. A seasonal or public-holiday minimum stay that the
campground has set for the whole campground (not for one category), weekly
letting for rental units (arrivals on one fixed weekday), a campground that is
closed outside its season, a minimum notice per category and a limit counted in days ahead (how far in
advance a guest may book) are all checked when you create the booking, and none
of them has a field in the feed. The feed does carry one date limit,
restrictions.max_booking_date, the last arrival date sold online; the
day-count limit is not in it. Treat the feed as the menu and the create call as
the final word, and handle its refusals (see
Create a booking).
Set restrictions yourself (optional)
Section titled “Set restrictions yourself (optional)”If your agreement lets you control stop-sell, minimum stay or arrival days, send
them with PUT ari/restrictions/ (scope ari:write). The body is a list of
rows, one per category and date, or {"restrictions": [...]}:
curl -X PUT https://api.campone.ch/api/v1/public/ari/restrictions/ \ -H "Authorization: Bearer $GRONDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '[ {"site_type_code": "standard", "date": "2027-07-17", "closed_to_arrival": true}, {"site_type_code": "standard", "date": "2027-07-18", "min_stay": 3}, {"site_type_code": "standard", "date": "2027-07-19"} ]'{ "upserted": 2, "deleted": 1 }- A row sets
min_stay(0 to 365),closed(stop-sell for that night),closed_to_arrivalandclosed_to_departure. Anything you leave out is “no restriction”. - A row with every knob at its default deletes the restriction for that category and date, as the third row above does. The answer counts the rows written and the rows deleted; a clearing row for a date that has no restriction deletes nothing and is not counted, so sending it again is harmless.
- An unknown
site_type_codeanswers400and nothing is written. GET ari/restrictions/lists the rows that exist. It returns only dates that carry a restriction, and itsendis inclusive, unlike the feed’s.- The restrictions are enforced for every booking made through Grondia for that category and night, the campground’s own reception included, not only for yours. Agree with the campground what you may set.
A change shows in the feed at once: a restriction write clears the cached answers.
Create a booking
Section titled “Create a booking”Push a reservation with POST bookings/
(scope bookings:write). Three things in the body matter for a channel:
external_reference: your own reservation id. Keep it under 200 characters, because a longer value is cut. Sending the same reference again returns the booking you created and never makes a second one.channel: a free-text label for your product, up to 64 characters. The campground sees it in its bookings list.booking_engine, in any spelling, is reserved and answers400.- Where to book: either
site_id(a pitch fromGET sites/) or a category:site_type_code, thecodefrom the feed. With a category, Grondia picks the pitch. A category can also be named bysite_type_idorsite_type_name(fromGET sites/); Grondia tries the id, then the code, then the name, and a name that matches no category exactly is matched by its beginning, so prefer the code. Whensite_idis sent, the category fields are ignored.check_in_date,check_out_dateandnum_adultsare required;num_youth,num_childrenand the guest’s name, email, address and nationality are optional.
A few more fields are optional:
language: the guest’s language as a two-letter code, such asdeoren. The campground’s messages to the guest go out in that language when it has them in that language, and in its default language otherwise.custom_fields: answers to the campground’s own booking-form questions, as an object of question key to answer. The campground gives you its keys; a key it has not set up is dropped without an error.extras: a list of{"code": "...", "quantity": 1}. When you book by category, an extra that needs electricity makes Grondia pick a pitch with power. Extras are not added to the booking or to its price.sourceis an older name forchannel, used only whenchannelis empty. Sendchannel.
curl https://api.campone.ch/api/v1/public/bookings/ \ -H "Authorization: Bearer $GRONDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_reference": "OTA-48213-1", "channel": "example-channel", "site_type_code": "standard", "check_in_date": "2027-07-14", "check_out_date": "2027-07-17", "num_adults": 2, "guest_first_name": "Anna", "guest_last_name": "Müller", "guest_email": "anna.mueller@example.com" }'import os
import requests
reservation = { "external_reference": "OTA-48213-1", "channel": "example-channel", "site_type_code": "standard", "check_in_date": "2027-07-14", "check_out_date": "2027-07-17", "num_adults": 2, "guest_first_name": "Anna", "guest_last_name": "Müller", "guest_email": "anna.mueller@example.com",}
response = requests.post( "https://api.campone.ch/api/v1/public/bookings/", json=reservation, headers={"Authorization": f"Bearer {os.environ['GRONDIA_API_KEY']}"}, timeout=30,)
if response.status_code in (200, 201): booking = response.json() # Store booking["id"] next to your reservation id, and check booking["status"].elif response.status_code in (409, 422): # The campground cannot give you this stay. Read the code, do not retry as is. print(response.json())else: response.raise_for_status()const reservation = { external_reference: "OTA-48213-1", channel: "example-channel", site_type_code: "standard", check_in_date: "2027-07-14", check_out_date: "2027-07-17", num_adults: 2, guest_first_name: "Anna", guest_last_name: "Müller", guest_email: "anna.mueller@example.com",};
const response = await fetch("https://api.campone.ch/api/v1/public/bookings/", { method: "POST", headers: { Authorization: `Bearer ${process.env.GRONDIA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(reservation),});
if (response.status === 200 || response.status === 201) { const booking = await response.json(); // Store booking.id next to your reservation id, and check booking.status.} else if (response.status === 409 || response.status === 422) { // The campground cannot give you this stay. Read the code, do not retry as is. console.log(await response.json());} else { throw new Error(`HTTP ${response.status}`);}A new booking answers 201:
{ "id": 48307, "booking_reference": "4C8E1B07A2", "guest_first_name": "Anna", "guest_last_name": "Müller", "guest_email": "anna.mueller@example.com", "site_id": 9071, "site_number": "A12", "site_category": "Standard pitch", "check_in_date": "2027-07-14", "check_out_date": "2027-07-17", "num_adults": 2, "num_youth": 0, "num_children": 0, "status": "pending", "total_price": "151.00", "created_at": "2026-10-19T09:12:44.104837Z", "updated_at": "2026-10-19T09:12:44.104837Z", "external_reference": "OTA-48213-1", "channel": "example-channel"}- Store
id. The list calls do not filter onexternal_reference, so theidis how you find the booking again for a cancel. total_priceis the total Grondia worked out for the stay. Here 151.00 is more than three nights at the feed’s 42.00, because the pitch rate is not the whole bill.site_numberis empty when the campground sells a category and does not show pitch numbers;site_categoryalways names the category.- The
201echoes the guest details you sent. A repeat (200) and every read redact them, so do not rely on reading them back. - The create call itself sends the guest no email. Your product sends its
own confirmation. When the booking is later confirmed, the campground’s own
confirmation email can still go out, so agree with the campground who tells
the guest what, and send
languageso that email is in the guest’s language.
Repeating a push
Section titled “Repeating a push”Send the same external_reference again and you get 200 and the same booking,
whether the first call timed out or you simply retried. Only the key that
created the booking gets it back, with guest details redacted. A reference that
another key, or a booking not made through this call, already holds answers
409 with {"detail": "external_reference_conflict"}. References are unique
per campground, so give yours a prefix of your own.
Two consequences are easy to miss:
- A repeat never changes the booking. The check looks only at the reference. Sending the same reference with new dates returns the original booking untouched, and the partner API has no call to change the dates or guests of a booking. To amend a reservation, cancel it and create a new one under a new reference.
- A reference is used up for good. If your first booking was cancelled or
released (
abandoned, orcancelled), the same reference returns that booking with its finalstatus. It does not make a new one. Always checkstatusin the answer.
Retrying safely covers the other writes and the
Idempotency-Key header.
What can come back
Section titled “What can come back”| Status | Body | What it means |
|---|---|---|
201 |
The booking | Created. |
200 |
The booking, guest details redacted | You already used this reference. Not a new booking. |
400 |
{"check_in_date": {"code": "min_nights_violation", "message": "..."}} |
A campground rule refuses the dates. Codes: min_nights_violation, season_min_nights_violation, max_nights_violation, weekly_turnover_violation, min_advance_days_violation, same_day_booking_blocked, booking_cutoff_violation, booking_horizon_exceeded, season_closed. |
400 |
{"detail": "site_not_found"} or {"detail": "site_or_site_type_required"} |
The site_id is unknown or not for sale online, or you named neither a site nor a category. |
400 |
A code such as MAX_OCCUPANCY under non_field_errors |
The party is larger than the pitch or the campground allows. |
400 |
{"vehicle_length_m": {"code": "vehicle_length_required", ...}} |
The campground demands the vehicle’s length (or its weight, vehicle_weight_required). Send vehicle_type, vehicle_length_m and vehicle_weight_kg. |
400 |
{"channel": ["This channel label is reserved."]} |
You used booking_engine. Sent in source, it is reported on source. |
400 |
{"detail": "create_failed"} |
Something unexpected failed. Retrying with the same reference is safe. If it persists, write to Felix or Siro. |
409 |
{"detail": "external_reference_conflict"} |
The reference belongs to someone else. |
409 |
{"detail": "no_pitch_available"}, {"detail": "site_unavailable", ...} or a code such as SITE_UNAVAILABLE or ADMIN_BLOCK under non_field_errors |
No pitch can be given for these dates, or the pitch is blocked. Pull the feed again and offer other dates. |
422 |
{"detail": "...", "code": "closed_to_arrival", "date": "2027-07-17"} |
A per-date restriction refuses the stay, and nothing is created. code is the first rule the stay breaks, in this order: stop_sell, closed_to_arrival, min_stay, closed_to_departure. date is the closed night for stop_sell, the departure date for closed_to_departure and the arrival date otherwise. |
Branch on code, never on message: the message is written for a guest and may
be in German or another language. Errors describes the four
shapes an error body comes in. Retrying a 400, 409 or 422 unchanged
gets the same answer. Retrying a timeout is safe, because the reference makes
the call idempotent.
Cancel a booking
Section titled “Cancel a booking”POST bookings/{id}/cancel/
(scope bookings:write, no body) cancels a booking under the campground’s own
cancellation and refund policy, the one a guest’s self-service cancel uses.
curl -X POST https://api.campone.ch/api/v1/public/bookings/48307/cancel/ \ -H "Authorization: Bearer $GRONDIA_API_KEY"{ "id": 48307, "booking_reference": "4C8E1B07A2", "status": "cancelled", "refund": { "refunded": false, "reason": "no_payment" }}- The
refundobject says what Grondia refunded. When it holds no payment for the booking, as above,refundedisfalsewith areason. When it refunds money,refundedistrueand the object carries decimal strings such asrefund_amount,cancellation_feeandoriginal_amount, among other fields. The set of fields varies with the case, so read only the ones you need. - The guest’s cancellation deadline does not apply to you. You may cancel up to the arrival day, but the campground’s cancellation fee still applies to the refund. For an amendment done as cancel plus new booking, agree with the campground how it treats that fee.
- The fee depends on the days left before arrival, counted on the campground’s calendar. A campground can also set a time of day after which a cancellation counts one day closer to arrival: with 11:00, a cancel at 11:30 on the day before arrival is charged as one on the arrival day.
- Only
reservation_request,pendingandconfirmedbookings can be cancelled. Any other status answers400with{"detail": "not_cancellable", "status": "cancelled"}, naming the current status. Treat that answer to a repeated cancel as done. - The call accepts any booking id of the campground, not only yours. Cancel
only ids you got from your own creates. An id that does not exist in this
campground answers
404. - The cancel does not switch off the campground’s own guest emails. The guest can receive the campground’s cancellation message, as with any cancel in its admin. That message states the refund the cancellation decided, and a refund the campground records later can send the guest a short message of its own. Agree with the campground who tells the guest.
Keep in step
Section titled “Keep in step”Webhooks
Section titled “Webhooks”Register one endpoint with
POST webhooks/
(scope webhooks:write):
curl https://api.campone.ch/api/v1/public/webhooks/ \ -H "Authorization: Bearer $GRONDIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://channel.example.com/hooks/grondia", "event_types": ["booking.created", "booking.confirmed", "booking.modified", "booking.cancelled"] }'The 201 answer carries the signing secret (it starts with whsec_) once.
Store it: no call shows it again. To get a new one, post the same URL again with
rotate_secret set to true; posting a URL that is already registered updates
it in place. The URL must be https on a public hostname; IP addresses, localhost and
private-network hosts are refused, and redirects are not followed.
- Which events. An endpoint with an empty
event_typeslist gets the nine classic events, which includepayment.settledandpitch.status_changed, rarely needed by a channel. Four events arrive only when you name them. Naming the fourbooking.*events above is the plain choice.booking.bookedandbooking.changedare the alternative:booking.bookedfires for every new booking whatever its first status,booking.changedfor every later change or confirmation. - Precedence, and a trap. Each change produces one of the classic booking
events, picked in this order: cancelled, confirmed, created, modified. A
booking that is created already confirmed therefore arrives as
booking.confirmedand never asbooking.created. A booking you create yourself startspending, so you seebooking.createdfor it (and, on a campground that takes payment on arrival,booking.confirmedmoments later). But the campground’s own bookings, which matter for your availability, can be the other kind. Treatbooking.confirmedfor an id you have never seen as a new booking. - Check
statusin every payload. An unpaid pending booking is released asabandoned, and that arrives asbooking.modified. The rarecancelledvariant arrives asbooking.cancelled(see A release that arrives as cancelled). - Your own writes come back to you. A booking you create or cancel fires
its event at your endpoint like any other. Recognise it by
external_reference. - The payload is small and holds no guest. It carries the booking
id,external_reference,booking_reference,status,check_in,check_out,site_type_code,total,currencyandupdated_at:
{ "event": "booking.confirmed", "tenant": "beispielsee", "booking": { "id": 48307, "external_reference": "OTA-48213-1", "booking_reference": "4C8E1B07A2", "status": "confirmed", "check_in": "2027-07-14", "check_out": "2027-07-17", "site_type_code": "standard", "total": "151.00", "currency": "CHF", "updated_at": "2026-10-19T09:40:12.511903+00:00" }}Each delivery is signed with an X-Grondia-Signature header, so check it before
you trust a body (Webhooks shows
how, with code); it also carries X-Grondia-Event, X-Grondia-Delivery and
X-Grondia-Timestamp. Answer with a 2xx within 10 seconds. A delivery that
fails is retried up to five times with growing pauses
(Retries). Every event is described
in the API reference, for example
booking.created,
booking.confirmed and
booking.cancelled.
Use an event to learn that something changed, then fetch the booking for the details. If you re-pull the feed on an event, remember it can be up to five minutes old.
The changed-bookings sweep
Section titled “The changed-bookings sweep”GET bookings/?updated_since=...
returns the bookings changed on or after a moment, oldest change first.
Keep the highest updated_at you have processed and send it next time; on the
first run use the moment you start syncing
(Syncing changes has the details, and
says why the sample below starts each request again from the last updated_at
instead of following next). Run it every few minutes: it catches a webhook
that never arrived, a change made at reception, and a cancellation. It does not
see a booking the campground deletes
(What neither of them sees).
curl "https://api.campone.ch/api/v1/public/bookings/?updated_since=2026-10-19T09:40:12Z&page_size=200" \ -H "Authorization: Bearer $GRONDIA_API_KEY"import os
import requests
URL = "https://api.campone.ch/api/v1/public/bookings/"session = requests.Session()session.headers["Authorization"] = f"Bearer {os.environ['GRONDIA_API_KEY']}"
def sweep(since): """Pass every booking changed at or after `since` to upsert(), and return the newest updated_at seen: where the next sweep starts.""" cursor, page, newest = since, 1, since while True: response = session.get( URL, params={"updated_since": cursor, "page_size": 200, "page": page}, timeout=10, ) response.raise_for_status() data = response.json() for booking in data["results"]: upsert(booking) # your function: by booking["id"], skip an old updated_at if not data["results"]: return newest newest = data["results"][-1]["updated_at"] if data["next"] is None: return newest if newest == cursor: page += 1 # 200 or more bookings share one updated_at (rare): page on else: cursor, page = newest, 1 # start again from the last updated_atconst headers = { Authorization: `Bearer ${process.env.GRONDIA_API_KEY}` };
// Pass every booking changed at or after `since` to upsert(), and return// the newest updated_at seen: where the next sweep starts.async function sweep(since) { let cursor = since; let page = 1; let newest = since; while (true) { const url = new URL("https://api.campone.ch/api/v1/public/bookings/"); url.searchParams.set("updated_since", cursor); url.searchParams.set("page_size", "200"); url.searchParams.set("page", String(page)); const response = await fetch(url, { headers }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); for (const booking of data.results) { await upsert(booking); // your function: by booking.id, skip an old updated_at } if (data.results.length === 0) return newest; newest = data.results[data.results.length - 1].updated_at; if (data.next === null) return newest; if (newest === cursor) { page += 1; // 200 or more bookings share one updated_at (rare): page on } else { cursor = newest; // start again from the last updated_at page = 1; } }}- The cursor is inclusive. The booking at the cursor comes back once more, so
upsert by
idand ignore a row whoseupdated_atyou already hold. Start each sweep a few minutes before the cursor you saved (why). - It lists the whole campground, not only your bookings: its website
bookings and reception bookings too, with guest details redacted. Your own
carry your
external_referenceandchannel. Website and reception bookings have both empty, but bookings that came in another way do not: an import of Booking.com reservations carries thechannelbooking_comand anexternal_referencethat starts withbdc:, and a booking pushed by another partner carries that partner’s. Recognise your own by your prefix, and never treat “has a reference” as “is mine”. statuscarries the news. A cancelled or released booking shows up withcancelledorabandoned. What a sweep cannot show is in What neither of them sees and Check-ins and check-outs.
Without updated_since the list is newest first by creation, which is not a
change feed. An invalid updated_since answers 400.
A release that arrives as cancelled
Section titled “A release that arrives as cancelled”A pending booking that nobody paid or confirmed is normally released by the
five-minute check, as abandoned, and that arrives as booking.modified.
There is one other way. If a booking for the same pitch and overlapping dates
is created after the 30 minutes are up but before the five-minute check has
run, that create releases the old booking itself, as cancelled. That release
arrives as booking.cancelled, and it moves updated_at, so the sweep sees it
too.
So treat abandoned and cancelled alike for a booking you still held as
pending: either way the campground no longer holds the pitch for you.
Before you go live
Section titled “Before you go live”- Payment and confirmation agreed with the campground, so your pending
bookings are paid, confirmed on arrival or confirmed by reception inside 30
minutes, and your code treats
abandonedandcancelledalike as the release of a booking you held aspending. - A key with your scopes from Felix or Siro, one key per campground, stored apart from your code.
- Categories mapped. Each campground’s
codevalues are stored per campground. A legacy category can have an emptycode; ask the campground to set one before you sell it. - Minimum stay read per category and arrival date, not once per campground.
- A reference scheme.
external_referenceis unique per campground, carries a prefix of your own, and a re-booking after a cancel or an amendment gets a new one. - Refusals handled by
code. A400,409or422shows the guest other dates and is never retried unchanged. - Webhook endpoint registered, the signature checked, the secret stored (Webhooks).
- A sweep running every few minutes with a stored cursor (Syncing changes).
- Request budget worked out against 1000 requests an hour per key, the default (Rate limits). A feed pull every five minutes plus a sweep every five minutes use 24 of them.
- Guest communication decided: who sends the confirmation and the cancellation notice, you or the campground.
- A pilot with one campground before you open the channel to the rest. The Partner program describes the review before it and the App Store listing after it.
What the sandbox can and cannot show
Section titled “What the sandbox can and cannot show”The sandbox key (see the Quickstart) is read-only, with
bookings:read, sites:read, invoices:read and customers:read. With it you
can call the two ARI reads and the booking list. You cannot create or cancel a
booking, write a restriction or register a webhook: those need
bookings:write, ari:write and webhooks:write. To try the whole flow, write
to Felix, felix@grondia.com, or to Siro,
siro@grondia.com.
Related
Section titled “Related”- The guides behind this page: Webhooks, Syncing changes, Retrying safely, Errors, Rate limits and Dates, times and money.
- Personal data: why guest names and emails come back redacted, and how to ask for access.
- The other recipes: Accounting and ERP, Guest messaging and CRM, Shop and restaurant tills and Zapier.
- When your channel works, ask for the review: Partner program.