Skip to content

Syncing changes

Most integrations keep a copy of some campground data: the bookings, the pitches, the invoices. This page is about keeping that copy right. The short version: let webhooks tell you about changes as they happen, and run a sweep with updated_since every few minutes for the ones they did not tell you about. A booking the campground deletes reaches neither of them (see What neither of them sees).

The lists of bookings, sites, customers, invoices and till products all answer in pages of the same shape:

{
"count": 312,
"next": "https://api.campone.ch/api/v1/public/bookings/?page=2",
"previous": null,
"results": [ ... ]
}
  • A page holds 50 items. Ask for up to 200 with page_size; a larger number is treated as 200.
  • page counts from 1. A page past the end answers 404 with {"detail": "Invalid page."}.
  • next and previous are null on the last and the first page.

Each list has its own order:

List Order
GET bookings/ Newest first, by when the booking was created. With updated_since: by updated_at, oldest first.
GET sites/ By site_number
GET customers/ Newest first
GET invoices/ Newest first
GET pos/products/ By category, then name
List Filters
bookings/ status: one status, such as confirmed. check_in_from, check_in_to: arrival on or after, on or before a date (YYYY-MM-DD); a value that is not a real date answers 400 with detail naming the parameter (Errors). updated_since: see below.
sites/ check_in and check_out together: only the sites free for the whole stay. One without the other, a date that is not YYYY-MM-DD, or a check_out that is not after check_in answers 400.
customers/, invoices/, pos/products/ None. page and page_size only.

status takes one value. A value that is not a status gives an empty list, not an error.

The site list holds only the pitches the campground offers to the public, and never an archived one.

GET bookings/?updated_since=... returns the bookings whose updated_at is at or after the value, ordered by updated_at and then id, oldest first. It is the way to ask “what changed since I last looked?”.

Terminal window
curl -G https://api.campone.ch/api/v1/public/bookings/ \
-H "Authorization: Bearer ck_a1b2cXXXXXXXXXXXXXXXXXXXXXXXX" \
--data-urlencode "updated_since=2026-07-01T08:30:00.104837Z" \
--data-urlencode "page_size=200"
  • The value is an ISO 8601 date and time, or a date alone, which means midnight (Dates, times and money says how to write the + of an offset in a URL). A value without a time zone is read in the campground’s time zone (Europe/Zurich for a Swiss campground), so send the updated_at values the API gave you, which end in Z. A value that is not a real date or date and time, such as yesterday or 2026-13-45, answers 400 with {"detail": "updated_since must be an ISO date or datetime."} (Errors). Do not retry it; fix the value.
  • At or after, not after: the booking you saw last comes back on the next call. Store bookings by id, so seeing one twice does no harm.
  • Only the booking list has it. Customers and invoices have no change filter; for invoices, listen to invoice.issued.

A sweep pages through a list that is changing under it. A booking that changes while you read moves from its place to the end of the list, every booking behind it moves up by one, and following next then skips one. Do not page with next: start each request again from the last updated_at you received.

# pip install requests (Python 3.11 or later)
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: str) -> str:
"""Pass every booking with updated_at at or after `since` to save_booking(),
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=30,
)
response.raise_for_status()
data = response.json()
for booking in data["results"]:
save_booking(booking) # insert or update by booking["id"]
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

Save the value sweep returns, and start the next sweep a few minutes before it:

from datetime import datetime, timedelta
cursor = load_cursor() or "2026-01-01T00:00:00Z"
start = datetime.fromisoformat(cursor) - timedelta(minutes=5)
save_cursor(sweep(start.isoformat()))

The overlap is our recommendation, not a server rule. updated_at is set when Grondia saves a booking, and the change becomes visible when the save is committed, a moment later. A booking saved just before your sweep can turn up after one with a later updated_at, and the overlap picks it up next time.

Neither one alone sees everything:

  • Webhooks can miss a change. A delivery that cannot be queued is not sent, and one that fails six times stays in the delivery log.
  • A sweep shows the booking, not what happened to it. A booking that changed twice since the last sweep comes back once, as it is now, and a check-in moves updated_at without showing anywhere else in the booking (below). The webhooks name each change.

So use both, and let them feed the same code:

  1. Webhook delivery booking.confirmed then fetch the booking
  1. Every few minutes
  2. Sweep with updated_since

Both lead to:

  1. save_booking() insert or update by id
  • On a booking webhook, fetch the booking with GET bookings/{id}/ and save it.
  • Every few minutes, run the sweep above. How often is up to you; each request counts against your key’s rate limit.
  • Before you act on one booking where it matters, such as billing the guest, fetch it again.

A deleted booking reaches neither. An operator can delete a booking that has no payment, no check-in and no settled or printed invoice, for example one typed in by mistake. No event is sent for it, and a deleted booking is in no list, so a sweep cannot find it either. If a deletion matters to your product, compare now and then: page through a check-in window (check_in_from, check_in_to) and look for the bookings you hold in that window that the list no longer has. Fetch each of them with GET bookings/{id}/: 404 means it was deleted.

Both reach you, but not always in the shape you expect:

  • Automatic completion is not a check-out. On its departure day, Grondia sets every booking that is still confirmed to completed by itself, at every campground, without recording a check-out. That sends booking.modified and moves updated_at, but no guest.checked_out. A campground can also switch on the same step for the evening before departure, or for the moment a guest has paid in full on the departure day; those send booking.modified too. So treat completed as the end of a stay, whether or not a guest.checked_out arrived. A campground can also switch on an automatic check-out of the guests who are still checked in once its check-out time has passed. That one is a real check-out: it is recorded like one at reception and sends guest.checked_out.
  • A check-in shows only in the event. A booking stays confirmed when the guest checks in, alone or with a whole group, and the API has no field for the check-in. The check-in moves updated_at, so a sweep returns the booking, but nothing in it says what changed; guest.checked_in does. If your product needs check-ins and cannot take webhooks, ask Felix, felix@grondia.com, or Siro, siro@grondia.com.

All of this works with the sandbox key: it reads bookings, sites, customers and invoices, and the sandbox has three bookings to page and filter. It cannot read till products (pos:read), and it cannot register webhooks.