Skip to content

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.

  1. Grondia to Your endpoint: POST booking.confirmed. Signed

  2. Your endpoint: Check the signature and the timestamp

  3. Your endpoint to Grondia: 204, within 10 seconds

  4. Your endpoint to Your worker: Queue the event

  5. Your worker to Grondia: GET bookings/48213/

  6. Grondia to Your worker: The booking, guest data redacted

Send the URL and the events you want to POST webhooks/. Here Camping Beispielsee’s key registers an endpoint for three booking events:

Terminal window
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 with 400. 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_types and is_active in place, and answers 200 without 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 with DELETE 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.

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

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, never booking.created. If you need one event for every new booking, subscribe to booking.booked.
  • A change that confirms a booking and records the check-in at the same time sends booking.confirmed only. That is what happens to a member of a group that is still pending when the group is checked in.
  • booking.modified does not say what changed. Read the body, or fetch the booking.

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 and guest.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 a standard booking. For more, fetch it with GET bookings/{id}/, where the personal-data rules apply.
  • payment: for payment.settled the amount, currency, status, payment provider, the booking it belongs to, when Grondia recorded the payment as paid (paid_at, not when the money arrived, and null when nothing recorded a time) and when the payment last changed (updated_at); for payment.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.

Anyone who learns your URL can post to it. Check each delivery before you act on it:

  1. Read the body as raw bytes, before any JSON parsing.
  2. Compute the HMAC-SHA256 of the X-Grondia-Timestamp value, a . and the raw body, keyed with your secret, and compare sha256= plus its hex digest with X-Grondia-Signature in constant time.
  3. 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.
  4. 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-signature.mjs
// 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;
}

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.mjs
import { 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);

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.

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.

signature-fixture.json
{
"secret": "whsec_test_beispielsee",
"timestamp": "1760875200",
"body": "{\"event\":\"booking.created\",\"tenant\":\"beispielsee\"}",
"header": "sha256=a35cfb20767cc235469580e03e73e901363bfea3b2a67b26e57b57082793f53f"
}
  • Any 2xx acknowledges 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 301 or 302 counts as a failure, so register the final URL.

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.

  • The same delivery can arrive more than once, for example when your answer took longer than 10 seconds. Keep the X-Grondia-Delivery ids you have handled, and answer 2xx without 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.modified can overtake the booking.created whose 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.

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.