Skip to content

Dates, times and money

Three kinds of value come back from the API. Each has one rule that prevents most bugs.

Kind Looks like What it means The rule
Stay date 2026-07-14 A day on the campground’s own calendar. No time, no zone. Never convert it. Compare it with the campground’s today, not yours.
Timestamp 2026-07-14T22:30:00Z A moment in time, in UTC. Convert it to the campground’s time zone before you take a day from it.
Amount "105.00" A decimal string with two places, in the campground’s currency. Parse it with a decimal type, never a float.

The rest of this page takes them in turn, starting with the time zone that gives stay dates their meaning. The Core concepts page has the short version.

Every campground has a time zone, and it belongs to the campground, not to you and not to Grondia’s servers. For a Swiss campground it is Europe/Zurich. A campground in another country has the zone of that country, for example Europe/Berlin for a German one: Grondia derives it from the campground’s country unless the zone was set by hand. Where nothing is known, it is Europe/Zurich.

The API does not return the zone, and neither do webhooks. Ask the campground that gave you the key, and store the zone next to the key.

Grondia’s servers run in UTC, but these calls work out “today” in the campground’s zone:

  • GET ari/ starts the feed at the campground’s today when you send no start, and moves an earlier start up to it.
  • Creating a booking is refused when the check-in is more than 30 days before the campground’s today. The online booking rules run on the campground’s clock too, for example a cutoff hour after which bookings for the next day close (booking_cutoff_hour in the ARI feed).

Use the same today when you compare or filter. If your servers run in UTC, their date is still yesterday’s for the first hour after midnight in a Swiss winter and for the first two hours in a Swiss summer. Work out the campground’s today from its zone:

from datetime import datetime
from zoneinfo import ZoneInfo
today = datetime.now(ZoneInfo("Europe/Zurich")).date()

Clocks change twice a year in Switzerland. That needs no care for stay dates, which are calendar days, or for timestamps, which are UTC. Only converting between the two does: let a time zone library do it from the zone name, and never add a fixed offset.

A stay date is YYYY-MM-DD, with no time and no zone. It means a day on the campground’s own calendar, in requests and in responses alike. Do not convert it: the check-in date 2026-07-14 is the 14th at the campground, wherever you are.

Where Fields
A booking check_in_date and check_out_date, in requests and responses
Booking list filters check_in_from and check_in_to, applied to check_in_date
Site availability check_in and check_out on GET sites/, which then lists only the sites free for the whole stay
ARI feed start, end, days[].date and restrictions.max_booking_date
ARI restrictions start and end, and date in each row you read or write
Booking events check_in and check_out in the booking object
Desk payments payment_date in payment.recorded, the date the campground recorded for the payment. It can be null.

A few rules follow from that:

  • A stay is counted in nights, and the departure day is not one. Anna Müller’s stay on pitch A12 from 2026-07-14 to 2026-07-17 is three nights. check_out_date must be after check_in_date, and a stay may not be longer than 365 nights.
  • The API has no times of day. A stay has a check-in date and a check-out date, and nothing for the hour of arrival or departure.
  • Creating a booking refuses dates that are far from today. A check-in more than 30 days in the past is refused, and so is one further ahead than the campground’s booking horizon, which is three years unless the campground has set another.

The two ARI calls read a window of dates, and their end parameters do not mean the same thing.

Call start end
GET ari/ The first night, inclusive. Default: the campground’s today. An earlier date is moved up to today. Exclusive: the day after the last night. Default: start plus 90 days.
GET ari/restrictions/ The first date, inclusive. Default: the campground’s today. Inclusive: the last date. Default: start plus 90 days.

Both calls cut a longer window down to 370 days after start without an error, so read start and end in the answer to see the window you got. In the feed, each entry of days is one night, and its date is the evening the night begins.

A timestamp is a moment, not a day. The API writes every timestamp in ISO 8601 and in UTC.

Where Fields
A booking created_at, updated_at
A customer date_joined
An invoice issued_at, the time the invoice was created
A till transaction created_at
booking.* events, guest.checked_in and guest.checked_out booking.updated_at
pitch.status_changed pitch.updated_at
guest.created guest.created_at
invoice.issued invoice.issued_at, the time the invoice was created
payment.settled payment.paid_at, when Grondia recorded the payment as paid (null when nothing recorded a time), and payment.updated_at, the latest of when the payment was created, paid and refunded
payment.recorded No timestamp: it carries the date payment_date (see Stay dates)
The X-Grondia-Timestamp header of a webhook Unix time in seconds, such as 1784068200. It is the time of that delivery attempt, and it changes on every retry.

payment.paid_at is the only place the API gives the time of an online payment: no call lists payments. It is when Grondia recorded the payment as paid, not when the money arrived. Grondia stamps it as it processes the payment provider’s confirmation, so a payment it learns of later, for example in a reconcile run after a missed confirmation, carries the later time. null means nothing recorded a time. X-Grondia-Timestamp is the time of the delivery attempt, not of the payment.

Responses and webhook bodies write the same moment slightly differently:

Where Written as
Responses to your calls 2026-07-14T22:30:00Z, or 2026-07-14T22:30:00.104837Z when the moment has fractions of a second
Webhook bodies 2026-07-14T22:30:00+00:00, or 2026-07-14T22:30:00.104837+00:00

Real values nearly always carry fractions of a second, so expect the longer form. Both forms mean UTC. Parse them with an ISO 8601 parser rather than comparing them as strings, because the same moment is two different strings. Python before 3.11 cannot read the trailing Z: replace it with +00:00 first.

Anna Müller books pitch A12 at 00:30 on 1 July, Swiss summer time. The booking’s created_at reads 2026-06-30T22:30:12.481903Z. Cutting the first ten characters gives 30 June, a day early. Convert to the campground’s zone first, and only then take the date:

from datetime import datetime
from zoneinfo import ZoneInfo
created_at = "2026-06-30T22:30:12.481903Z"
moment = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
print(moment.astimezone(ZoneInfo("Europe/Zurich")).date()) # 2026-07-01

GET bookings/ takes an updated_since parameter that lists the bookings whose updated_at is at or after a moment, oldest first. It is the only place where Grondia acts on a timestamp you send, and it reads the value like this:

You send Grondia reads it as
2026-07-14T22:30:00Z That moment.
2026-07-15T00:30:00%2B02:00 That moment, with the plus sign written as %2B.
2026-07-15T00:30:00 00:30 on the campground’s clock. A value with no offset is read in the campground’s zone, not in UTC.
2026-07-15 The start of that day in the campground’s zone.
2026-07-14T22:30:00+00:00, with the plus sign as it is Refused with 400. A URL turns a plus sign into a space.

Let your HTTP client encode the parameter, or send Z. With curl, --data-urlencode does the encoding:

Terminal window
curl -G https://api.campone.ch/api/v1/public/bookings/ \
-H "Authorization: Bearer ck_a1b2cXXXXXXXXXXXXXXXXXXXXXXXX" \
--data-urlencode "updated_since=2026-07-14T22:30:00+00:00"

To follow changes, remember the newest updated_at you have seen and send it unchanged next time: it ends in Z, so it needs no encoding. The filter means “at or after”, so the booking at the boundary comes back once more. Recognise it by its id and updated_at and skip it.

Till transactions have no time of their own

Section titled “Till transactions have no time of their own”

A till transaction accepts an occurred_at timestamp, but Grondia does not store it. The transaction is dated by the moment Grondia receives it, which is its created_at. A sale that your till posts late is therefore dated late.

Every amount is a decimal string with two decimal places, such as "105.00". It is a string on purpose: a JSON number becomes a binary float in most languages, and a float cannot hold 0.10 exactly.

Where Fields
Bookings total_price, such as "139.50"
Sites base_price_per_night, such as "42.00". This is the pitch’s base price, not what a stay costs: use a booking’s total_price or the ARI feed for that.
Invoices total_chf, such as "139.50"
ARI feed days[].price, such as "42.00". This is the nightly price of the pitch alone. Person fees, Kurtaxe and extras are not in it.
Till products unit_price, such as "4.50", and tax_rate, a percentage such as "8.10"
Till transactions total_amount, items[].unit_price and items[].line_total. items[].quantity has three places, such as "1.500".
Cancel answer The amounts in refund when money is refunded, such as cancellation_fee and refund_amount
Events booking.total in booking events, payment.amount in payment.settled and payment.recorded, and invoice.total in invoice.issued, each with a currency beside it.

A booking’s total_price includes the Kurtaxe where the campground charges one (see the glossary). You cannot set a price: total_price is read-only, and Grondia prices the booking you create from the campground’s own prices.

Read an amount with a decimal type. Python has one built in. JavaScript has none, so keep the string, or count in whole cents with BigInt:

from decimal import Decimal
total = Decimal(booking["total_price"]) # Decimal("139.50")
print(Decimal("0.10") + Decimal("0.20")) # 0.30
print(0.10 + 0.20) # 0.30000000000000004

A campground has one currency for its prices: CHF unless it is set up with another, written as an ISO 4217 code. Only two places name it:

  • the currency field of the ARI feed, and
  • the currency field of a webhook payload, which names the currency of that booking, payment or invoice.

The responses of the other calls carry amounts with no currency beside them. Read the currency once with GET ari/ (the sites:read scope is enough) and store it with the key, with the time zone.

An invoice’s total_chf is named for the Swiss franc, but it holds the invoice’s total as Grondia stores it, in the invoice’s own currency, with no conversion.

Grondia rounds amounts to two decimal places, and invoice totals half up. A campground can also set a rate to round its unit prices in steps of 0.05, which in francs is 5 Rappen, the smallest Swiss coin, so that prices from that rate are multiples of 0.05, such as 38.05 and never 38.03. The rate decides it; the API only shows the result.

Take the amounts as you receive them:

  • Do not round them again, and do not recompute a total from its parts.
  • Do not assume that every amount is a multiple of 0.05. Only prices from a rate with that setting are.

You never send a price with a booking. The one call that takes amounts is POST pos/transactions/, and it checks them:

  • Amounts cannot be negative.
  • Each line_total must be within 0.01 of quantity times unit_price, or the call answers 400. Grondia stores the product of the two, to two decimals.
  • total_amount must be within 0.01 of the sum of the line totals, or the call answers 400. Grondia stores the sum.

Send line totals that already add up, and you will not see either error.