Webhooks
Instead of asking Grondia every few minutes whether something changed, register
an endpoint and let Grondia tell you. When a booking is created, confirmed,
changed or cancelled, a guest checks in or out, a payment or an invoice comes
in, or a pitch changes its status, Grondia sends a signed POST with a small
JSON body to each of the campground’s endpoints that asked for that event.
Grondia to Your endpoint:
POST booking.confirmed. SignedYour endpoint: Check the signature and the timestamp
Your endpoint to Grondia: 204, within 10 seconds
Your endpoint to Your worker: Queue the event
Your worker to Grondia:
GET bookings/48213/ Grondia to Your worker: The booking, guest data redacted
Register an endpoint
Section titled “Register an endpoint”Send the URL and the events you want to
POST webhooks/.
Here Camping Beispielsee’s key registers an endpoint for three booking events:
curl https://api.campone.ch/api/v1/public/webhooks/ \ -H "Authorization: Bearer ck_a1b2cXXXXXXXXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/grondia/beispielsee", "event_types": ["booking.confirmed", "booking.modified", "booking.cancelled"]}'The answer is 201 Created, and it carries the signing secret:
{ "id": "2f1c6a0e-8d4b-4f7a-9c3e-5b1d7e9a4c20", "url": "https://hooks.example.com/grondia/beispielsee", "is_active": true, "event_types": ["booking.confirmed", "booking.modified", "booking.cancelled"], "created_at": "2026-07-01T08:30:00.104837Z", "updated_at": "2026-07-01T08:30:00.104837Z", "secret": "whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}- The secret is shown once. Store it like a password. Grondia leaves it out of every list and cannot show it again.
- The URL must be
https, on a public hostname, with a certificate that is valid for that name. IP addresses,localhost, internal names, hostnames that resolve to a private address and a user name or password in the URL are refused with400. Any port is fine. To develop on your own machine, use a tunnel that gives it a public HTTPS hostname. - One endpoint per URL and campground. Posting a URL the campground already
has changes that endpoint’s
event_typesandis_activein place, and answers200without the secret. A field you leave out keeps its value, so you can send only the one you change. - A new secret: post the same URL again with
"rotate_secret": true. The answer carries the new secret, and from then on every delivery is signed with it, retries of earlier ones included. Until you store it, your endpoint refuses deliveries and Grondia retries them, so store it within a few minutes. - Pause an endpoint with
"is_active": false, or remove it withDELETE webhooks/{id}/(204). Either way, deliveries still waiting for a retry are dropped.GET webhooks/lists the campground’s endpoints.
A key belongs to one campground, so every campground that uses your product
registers its own endpoint, with its own secret. Give each one its own URL, as
above, so your endpoint knows which secret to check before it reads the body.
The body names the campground too, in tenant.
Events
Section titled “Events”event_types lists the events an endpoint gets. An empty list, or none at all,
means the nine default events. The four opt-in events reach an endpoint only
when it names them, so an endpoint that wants all thirteen lists all thirteen.
An unknown name is refused with 400.
| Event | Sent when | Body |
|---|---|---|
booking.created |
A booking was created that is neither confirmed nor cancelled, for example pending or reservation_request |
booking |
booking.confirmed |
A booking became confirmed, or was created confirmed. When one payment confirms the bookings of a group (several bookings made together, confirmed or checked in as one), each of them sends it. |
booking |
booking.modified |
A booking’s dates, number of guests, total or status changed, and none of the other events applies. A pending booking released as abandoned arrives as this event. |
booking |
booking.cancelled |
A booking was cancelled, or created cancelled | booking |
guest.checked_in |
The guest’s check-in was recorded, for one booking or for a whole group at once. For a member that is still pending when the group is checked in, the event is booking.confirmed instead (why). |
booking |
guest.checked_out |
The guest’s check-out was recorded, for one booking or for a whole group at once. The campground’s opt-in automatic check-out of guests who are still checked in sends it too. Not sent for automatic completion on the departure day, which completes every stay that reception did not check out: it records no check-out and sends booking.modified instead (both explained). |
booking |
payment.settled |
An online payment succeeded | payment |
invoice.issued |
An invoice was created | invoice |
pitch.status_changed |
A pitch’s status changed between active, maintenance and inactive |
pitch |
The opt-in events:
| Event | Sent when | Body |
|---|---|---|
guest.created |
A guest profile was created | guest |
payment.recorded |
A payment was taken at reception. Refunds and payments carried over from a previous system are not sent. | payment |
booking.booked |
Any new booking, whatever its first status. Sent in addition to booking.created, booking.confirmed or booking.cancelled. |
booking |
booking.changed |
A booking was changed or confirmed after it was made. Sent in addition to booking.modified or booking.confirmed, and not for the saves that are part of making the booking. |
booking |
Which booking event you get
Section titled “Which booking event you get”Each time Grondia saves a booking, it sends at most one of the default booking
events, the first that applies in this order: booking.cancelled,
booking.confirmed, booking.created, guest.checked_in,
guest.checked_out, booking.modified. So:
- A booking created already confirmed sends
booking.confirmed, neverbooking.created. If you need one event for every new booking, subscribe tobooking.booked. - A change that confirms a booking and records the check-in at the same time
sends
booking.confirmedonly. That is what happens to a member of a group that is stillpendingwhen the group is checked in. booking.modifieddoes not say what changed. Read the body, or fetch the booking.
What a delivery looks like
Section titled “What a delivery looks like”A delivery is a POST with Content-Type: application/json and four headers
of its own:
| Header | What it holds |
|---|---|
X-Grondia-Event |
The event, such as booking.confirmed. The body repeats it in event. |
X-Grondia-Delivery |
The delivery’s id, a UUID. It stays the same on every retry of this delivery. |
X-Grondia-Timestamp |
Unix time in seconds at which this attempt was signed. New on every attempt. |
X-Grondia-Signature |
sha256= and the hex HMAC-SHA256 of the timestamp, a . and the raw body, keyed with your secret. New on every attempt. |
A booking event for Camping Beispielsee, laid out here for reading:
{ "booking": { "booking_reference": "K7M2Q9X4AB", "check_in": "2026-07-14", "check_out": "2026-07-17", "currency": "CHF", "external_reference": "", "id": 48213, "site_type_code": "standard", "status": "confirmed", "total": "105.00", "updated_at": "2026-07-01T08:30:00.104837+00:00" }, "event": "booking.confirmed", "tenant": "beispielsee"}On the wire the body is compact JSON with its keys in alphabetical order, and
the signature covers exactly those bytes. Timestamps in a body are UTC written
with +00:00, where the rest of the API writes Z; parse both
(Timestamps has the rest of the
formats).
The bodies are small on purpose, and none of them carries personal data (why):
- booking (the
booking.*events andguest.checked_*): ids and references, status, dates, total, currency and the code of the pitch type. Not the guest, and not the pitch itself: Anna Müller’s stay on A12 arrives as astandardbooking. For more, fetch it withGET bookings/{id}/, where the personal-data rules apply. - payment: for
payment.settledthe amount, currency, status, payment provider, the booking it belongs to, when Grondia recorded the payment as paid (paid_at, not when the money arrived, andnullwhen nothing recorded a time) and when the payment last changed (updated_at); forpayment.recorded"kind": "desk", the amount, currency, method and day. - invoice: its number, total, currency, status, the booking it belongs to and when it was issued.
- pitch: its id, number, new status and the code of its type.
- guest: the guest profile’s id and the campground’s guest number.
The API reference lists every field:
WebhookBooking,
WebhookPayment,
WebhookDeskPayment,
WebhookInvoice,
WebhookPitch,
WebhookGuest.
The body is built when the attempt is sent, from the record as it is then,
not as it was when the event happened. A delivery that waited for a retry can
show a newer state than its name suggests, for example a booking.created
whose status is already confirmed, and a retry can carry a different body
from the first attempt. If the record no longer exists, nothing is sent. Read
the event name as what happened, and the body as the state at sending time.
Verify every delivery
Section titled “Verify every delivery”Anyone who learns your URL can post to it. Check each delivery before you act on it:
- Read the body as raw bytes, before any JSON parsing.
- Compute the HMAC-SHA256 of the
X-Grondia-Timestampvalue, a.and the raw body, keyed with your secret, and comparesha256=plus its hex digest withX-Grondia-Signaturein constant time. - Refuse a timestamp more than five minutes away from your clock. This is our recommendation, not a rule the server enforces: the signature covers the timestamp, so a captured delivery sent to you again later fails the check. Keep your server’s clock in sync.
- Only then parse the JSON.
These two files do steps 2 and 3. They are the files this site’s test suite runs against a delivery signed by Grondia’s own signing code, copied here unchanged.
// Verify a Grondia webhook delivery. Node 18 or later, no dependencies.import { createHmac, timingSafeEqual } from 'node:crypto';
/** * True when `header` (the X-Grondia-Signature value) is the signature * Grondia computes for this timestamp and body with your webhook secret: * `sha256=` and the hex HMAC-SHA256 of `timestamp + "." + rawBody`. * * `rawBody` is the request body exactly as it arrived, as a string or a * Buffer. Parse the JSON after this check, never before: parsing and * re-serialising changes the bytes, and the signature no longer matches. */export function verifySignature(secret, timestamp, rawBody, header) { if (typeof header !== 'string') return false; const mac = createHmac('sha256', secret); mac.update(`${timestamp}.`); mac.update(rawBody); const expected = `sha256=${mac.digest('hex')}`; const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(header, 'utf8'); // Constant-time comparison, so the time taken reveals nothing about the // expected value. timingSafeEqual needs two buffers of the same length. return a.length === b.length && timingSafeEqual(a, b);}
// Our recommendation, not a server rule: accept a delivery signed in the// last 5 minutes.export const TOLERANCE_SECONDS = 300;
/** * True when `timestamp` (the X-Grondia-Timestamp value, Unix seconds) lies * within `toleranceSeconds` of `nowSeconds`. A captured delivery replayed * to you later fails this check, because its signature covers the timestamp. */export function isRecent( timestamp, nowSeconds = Date.now() / 1000, toleranceSeconds = TOLERANCE_SECONDS,) { if (typeof timestamp !== 'string' || !/^[0-9]+$/.test(timestamp)) return false; return Math.abs(nowSeconds - Number(timestamp)) <= toleranceSeconds;}"""Verify a Grondia webhook delivery. Python 3.10 or later, no dependencies."""import hashlibimport hmacimport time
# Our recommendation, not a server rule: accept a delivery signed in the# last 5 minutes.TOLERANCE_SECONDS = 300
def verify_signature( secret: str, timestamp: str, raw_body: bytes, header: str) -> bool: """True when ``header`` (the X-Grondia-Signature value) is the signature Grondia computes for this timestamp and body with your webhook secret: ``sha256=`` and the hex HMAC-SHA256 of ``timestamp + "." + raw_body``.
``raw_body`` is the request body exactly as it arrived, as bytes. Parse the JSON after this check, never before: parsing and re-serialising changes the bytes, and the signature no longer matches. """ if not isinstance(header, str): return False digest = hmac.new( secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256, ).hexdigest() # Constant-time comparison, so the time taken reveals nothing about the # expected value. expected = ("sha256=" + digest).encode("utf-8") return hmac.compare_digest(expected, header.encode("utf-8"))
def is_recent( timestamp: str, now: float | None = None, tolerance: int = TOLERANCE_SECONDS) -> bool: """True when ``timestamp`` (the X-Grondia-Timestamp value, Unix seconds) lies within ``tolerance`` seconds of ``now``. A captured delivery replayed to you later fails this check, because its signature covers the timestamp. """ if not isinstance(timestamp, str): return False if not (timestamp.isascii() and timestamp.isdigit()): return False if now is None: now = time.time() return abs(now - int(timestamp)) <= toleranceWire it up
Section titled “Wire it up”A receiver with no framework, next to the file above. Grondia only calls
https URLs, so put it behind your TLS proxy.
// server.mjs, run with: GRONDIA_WEBHOOK_SECRET=whsec_... node server.mjsimport { createServer } from 'node:http';
import { isRecent, verifySignature } from './verify-signature.mjs';
const secret = process.env.GRONDIA_WEBHOOK_SECRET;
createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); // the raw bytes, not parsed yet const timestamp = req.headers['x-grondia-timestamp']; const signature = req.headers['x-grondia-signature']; if (!verifySignature(secret, timestamp, body, signature) || !isRecent(timestamp)) { res.writeHead(401).end(); return; } const delivery = req.headers['x-grondia-delivery']; const event = JSON.parse(body.toString('utf8')); // Store the event under its delivery id, answer, and do the work later. console.log(delivery, event.event, event.tenant); res.writeHead(204).end(); });}).listen(3000);# server.py, run with: GRONDIA_WEBHOOK_SECRET=whsec_... python3 server.pyimport jsonimport osfrom http.server import BaseHTTPRequestHandler, HTTPServer
from verify_signature import is_recent, verify_signature
SECRET = os.environ["GRONDIA_WEBHOOK_SECRET"]
class GrondiaWebhook(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", 0))) # raw bytes timestamp = self.headers.get("X-Grondia-Timestamp", "") signature = self.headers.get("X-Grondia-Signature", "") if not (verify_signature(SECRET, timestamp, body, signature) and is_recent(timestamp)): self.send_response(401) self.end_headers() return delivery = self.headers["X-Grondia-Delivery"] event = json.loads(body) # Store the event under its delivery id, answer, and do the work later. print(delivery, event["event"], event["tenant"]) self.send_response(204) self.end_headers()
HTTPServer(("", 3000), GrondiaWebhook).serve_forever()In a framework, take the raw body the framework hands you before it parses
JSON: request.get_data() in Flask, request.body in Django,
express.raw({ type: 'application/json' }) on the route in Express.
Check your code against an example
Section titled “Check your code against an example”Grondia’s own signing code signed this delivery. Your check must accept it with this secret, and refuse it when any byte of the body, the timestamp or the secret changes. Its timestamp is from 2025 (19 October, 12:00 UTC), so leave the five-minute check out of this test.
{ "secret": "whsec_test_beispielsee", "timestamp": "1760875200", "body": "{\"event\":\"booking.created\",\"tenant\":\"beispielsee\"}", "header": "sha256=a35cfb20767cc235469580e03e73e901363bfea3b2a67b26e57b57082793f53f"}Answer fast
Section titled “Answer fast”- Any
2xxacknowledges the delivery. Grondia does not read what you send back. - Answer within 10 seconds. Grondia waits no longer: a slower answer counts as a failure, and the delivery comes again even if you processed it. Store the event, answer, and do the work afterwards.
- Redirects are not followed. A
301or302counts as a failure, so register the final URL.
Retries
Section titled “Retries”A delivery fails when there is no answer within 10 seconds, the connection
fails, or the status is anything but 2xx. Grondia then tries again, up to 5
times: 30, 60, 120, 240 and 480 seconds after the attempt before. That is six
attempts over about a quarter of an hour. After the last one, Grondia gives up,
and the delivery stays in the delivery log, where
you can replay it.
Every attempt keeps the delivery’s X-Grondia-Delivery id, and gets a new
timestamp, a new signature and a body built at that moment. A URL that fails the
https and public-hostname check at sending time, for example because its name
now points to a private address, is not retried.
De-duplicate, and do not count on order
Section titled “De-duplicate, and do not count on order”- The same delivery can arrive more than once, for example when your answer
took longer than 10 seconds. Keep the
X-Grondia-Deliveryids you have handled, and answer2xxwithout handling a delivery again. - Each endpoint gets its own deliveries. Two endpoints that ask for the same event each receive it, with different ids. A replay you ask for is a new delivery with a new id.
- Deliveries can arrive out of order. Each one is sent and retried on its
own, so a
booking.modifiedcan overtake thebooking.createdwhose first attempt failed. Treat a delivery as a reason to look: when order matters, fetch the booking. - A delivery can be missing. Grondia queues it after the change is saved, and if queueing fails, no delivery is sent. Back your webhooks with a periodic sweep, as Syncing changes describes.
Delivery log and replay
Section titled “Delivery log and replay”GET webhooks/{id}/deliveries/
returns an endpoint’s latest 200 deliveries, newest first, one row per
delivery, updated after each attempt:
[ { "id": "6b1f0f0e-4c1e-4a7e-9a52-2f8d1c3b9e10", "event": "booking.confirmed", "resource_type": "booking", "resource_id": 48213, "status_code": 503, "attempts": 6, "last_error": "HTTP 503", "created_at": "2026-07-01T08:30:01.512344+00:00" }]status_code is your endpoint’s last answer, or 0 when there was none (a
timeout or a connection error). last_error is HTTP and that status, or the
name of the error, such as ReadTimeout; it never holds your response body.
resource_type is booking, payment, invoice, site (for
pitch.status_changed), guest or manual_payment (for payment.recorded).
To send a delivery again,
POST webhooks/deliveries/{delivery_id}/replay/.
The answer is 202 Accepted with the id of the new delivery:
{ "delivery_id": "0c9e7f4a-1b2d-4e6f-8a3c-5d7e9f1a2b4c", "status": "enqueued" }The replay is a new delivery of the same event about the same record, to the
same endpoint, with the body built as the record is now. The original row
stays as it was. 202 means queued, not delivered: look for the new id in the
log. The replay answers 404 (not_found) for an unknown delivery, 409
(webhook_inactive) when its endpoint was removed or paused, and 503
(enqueue_failed) when it could not be queued; try again later.
Worked examples
Section titled “Worked examples”- Channel managers: a webhook endpoint beside the changed-bookings sweep, for bookings made on other channels.
- Accounting and ERP: which payments raise an event and which do not.
- Guest messaging and CRM: the
opt-in
booking.bookedandbooking.changedevents. - Zapier: the same events, behind a no-code app.
- Partner program: the review list asks you to check the signature of every delivery.