Shop and restaurant tills
A campground’s shop, kiosk or restaurant often runs on its own till. This recipe connects that till to Grondia: every sale is posted as it is rung up and recorded next to the campground’s own till sales, and you can read the campground’s article list to keep your own in step.
What the campground gets
Section titled “What the campground gets”- Each sale stored once. A sale you post becomes a confirmed till transaction in Grondia, marked as coming from an external till. Posting the same sale twice does not create a second one.
- Lines tied to the campground’s articles. A line that names a known
skuorbarcodeis linked to that article, and an article that tracks stock comes off the shelf. - A place to look. The administrator sees the recent sales and a sync log on the External POS system card in the admin.
What it does not do: Grondia is not the payment processor here. You post a sale that the guest has already paid for. The request has no booking and no guest, so it cannot charge a sale to a guest’s pitch, and there is no way to return, void or cancel a sale through the API.
Scopes and keys
Section titled “Scopes and keys”| Scope | Opens | Needed for |
|---|---|---|
pos:write |
POST pos/transactions/ |
Posting sales |
pos:read |
GET pos/products/, GET pos/transactions/{external_ref}/ |
Reading the article list and reconciling |
The campground’s administrator creates a till key on the External POS
system card (App Store, section “Operations & staff”): name the key, tick
pos:write and, if your till reads articles or checks sales, pos:read. The
key is shown once. The Developer API screen does not offer the pos:* scopes;
this card does.
What the sandbox cannot show
Section titled “What the sandbox cannot show”The standard sandbox key is read-only and carries only the read scopes for bookings, sites, invoices and customers. It can neither post a sale nor read the article list. For a test campground with a till key, write to Felix, felix@grondia.com, or to Siro, siro@grondia.com.
How the pieces fit
Section titled “How the pieces fit”Anna Müller, on pitch A12, buys two espressos and a croissant at the Beispielsee kiosk and pays with TWINT at the till. Your till has already taken the money; it now tells Grondia.
Your till to Grondia API:
GET pos/.products/ pos:readGrondia API to Your till: Active articles with
skuandbarcodeYour till: Anna pays at the till
Your till to Grondia API:
POST pos/. With antransactions/ external_ref(pos:write)Grondia API to Your till: 201 Created.
idempotent_replayfalseYour till to Grondia API: Same
POSTagain after a timeoutGrondia API to Your till: 200 OK.
idempotent_replaytrue, no second saleYour till to Grondia API:
GET pos/.transactions/ {external_ref}/ pos:readGrondia API to Your till: The stored sale, for reconciliation
The request names neither Anna nor her pitch: this endpoint carries no guest and no booking.
Post a sale
Section titled “Post a sale”external_ref is your till’s own id for the sale. It is the idempotency key:
choose one that stays the same when you retry the same sale, and never reuse it
for another (Retrying safely
has the rules). Send JSON, since the lines are a list, and amounts as decimal
strings (Amounts you send).
curl https://api.campone.ch/api/v1/public/pos/transactions/ \ -H "Authorization: Bearer ck_a1b2cXXXXXXXXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "kiosk-2026-07-15-000417", "payment_method": "TWINT_QR", "total_amount": "11.50", "items": [ {"description": "Espresso", "quantity": "2", "unit_price": "3.50", "line_total": "7.00", "sku": "CAFE-ESP"}, {"description": "Croissant", "quantity": "1", "unit_price": "4.50", "line_total": "4.50", "barcode": "2000000000008"} ] }'# pip install requestsimport osimport time
import requests
key = os.environ["GRONDIA_API_KEY"]sale = { "external_ref": "kiosk-2026-07-15-000417", "payment_method": "TWINT_QR", "total_amount": "11.50", "items": [ {"description": "Espresso", "quantity": "2", "unit_price": "3.50", "line_total": "7.00", "sku": "CAFE-ESP"}, {"description": "Croissant", "quantity": "1", "unit_price": "4.50", "line_total": "4.50", "barcode": "2000000000008"}, ],}
def post_sale(sale): # Retrying is safe: the same external_ref never records a second sale. for attempt in range(3): try: response = requests.post( "https://api.campone.ch/api/v1/public/pos/transactions/", json=sale, headers={"Authorization": f"Bearer {key}"}, timeout=10, ) except requests.RequestException: response = None # timeout or connection error if response is not None: if response.status_code in (200, 201): # 201 first time, 200 on a repeat return response.json() if response.status_code < 500 and response.status_code != 429: raise RuntimeError(f"HTTP {response.status_code}: fix the request") time.sleep(2 ** attempt) # a timeout, a 5xx or a 429: wait, then post again raise RuntimeError("No answer: the sale may or may not be stored")
print(post_sale(sale)["transaction_number"])// post-sale.mjs, run with: node post-sale.mjsconst key = process.env.GRONDIA_API_KEY;const sale = { external_ref: "kiosk-2026-07-15-000417", payment_method: "TWINT_QR", total_amount: "11.50", items: [ { description: "Espresso", quantity: "2", unit_price: "3.50", line_total: "7.00", sku: "CAFE-ESP" }, { description: "Croissant", quantity: "1", unit_price: "4.50", line_total: "4.50", barcode: "2000000000008" }, ],};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function postSale(sale) { // Retrying is safe: the same external_ref never records a second sale. for (let attempt = 0; attempt < 3; attempt++) { let response = null; try { response = await fetch( "https://api.campone.ch/api/v1/public/pos/transactions/", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", }, body: JSON.stringify(sale), signal: AbortSignal.timeout(10_000), }, ); } catch { // timeout or connection error: fall through and post again } if (response) { // 201 the first time, 200 on a repeat if (response.status === 200 || response.status === 201) return response.json(); if (response.status < 500 && response.status !== 429) { throw new Error(`HTTP ${response.status}: fix the request`); } } await sleep(2 ** attempt * 1000); // a timeout, a 5xx or a 429: wait, then post again } throw new Error("No answer: the sale may or may not be stored");}
console.log((await postSale(sale)).transaction_number);The answer is 201 Created the first time and 200 OK for a repeat of the same
external_ref. Treat both as success. A repeat returns the sale as first
stored, even if the body you sent differs; nothing is updated. Both carry the
stored sale and idempotent_replay:
{ "id": 18231, "transaction_number": "TXN-20260715084210-9F3A21C7", "source": "external_pos", "external_ref": "kiosk-2026-07-15-000417", "payment_method": "TWINT_QR", "total_amount": "11.50", "status": "CONFIRMED", "created_at": "2026-07-15T08:42:10.512903Z", "items": [ { "id": 40112, "description": "Espresso", "quantity": "2.000", "unit_price": "3.50", "line_total": "7.00", "sku": "CAFE-ESP" }, { "id": 40113, "description": "Croissant", "quantity": "1.000", "unit_price": "4.50", "line_total": "4.50", "sku": "BAK-CRS" } ], "idempotent_replay": false}The croissant line named only a barcode. Grondia found the campground’s article
for it and the answer shows that article’s sku. A line that matches nothing
comes back with an empty sku.
payment_method is one of CASH, WORLDLINE, WALLEE, POSTCARD, TWINT_QR,
ADYEN, BANK_TRANSFER, VOUCHER, AUFS_HAUS, STRIPE, CHARGE_TO_PITCH or
CASH_PAYOUT. Send the one that says how the guest paid. They are labels on a
recorded sale: posting CHARGE_TO_PITCH does not charge any pitch.
Read the article list
Section titled “Read the article list”GET pos/products/
returns the campground’s active articles, ordered by category and name, in
pages of 50 (up to 200 with ?page_size=200). Each has id, sku, barcode,
name, category (SHOP, SERVICE, RESTAURANT or OTHER), unit_price,
unit_name, tax_rate and is_active. Send a sku or barcode back on a sale line to
link it. The prices you post are the ones Grondia stores: it never replaces an
amount with the article’s price.
Edge cases
Section titled “Edge cases”| Situation | What happens | What to do |
|---|---|---|
| More requests than the key’s hourly limit (Rate limits) | 429 with a Retry-After header |
Wait, then post again with the same external_ref. The limit is 1000 an hour unless the key was set otherwise, and posting and reading share it |
The call times out, the connection drops or Grondia answers with a 5xx |
You do not know whether the sale was stored | Post it again with the same external_ref, or read it back; a 404 means it was not stored |
| A line total, or the total, is off by more than 0.01 | 400, naming the field |
Fix the arithmetic. Within a cent, Grondia stores its own figures: each line as quantity times unit price, the total as the sum of the lines |
| A quantity, price or total is negative | 400. There is no returns path |
Record a return at the campground’s own till |
items is empty, external_ref is blank or longer than 128 characters, or payment_method is not in the list |
400 |
Fix the request |
A sku is unknown, or a barcode is malformed or has a bad check digit |
The sale is stored and the line is left unlinked. A sale is never refused for this | Check the article list. If both are sent and name different articles, the sku wins |
external_ref contains a / |
The sale is stored, but the read-back address cannot hold it | Use letters, digits, - and _ |
occurred_at and cashier_external_id |
Accepted and ignored. created_at is when Grondia received the sale, not when it was rung up (Till transactions have no time of their own) |
An offline till keeps its own times and reconciles on external_ref |
| Who rang the sale up | Grondia names the person who created the key, else an administrator of the campground | Do not rely on a cashier name |
| The campground uses another currency | There is no currency field: a sale takes the campground’s currency, CHF unless it is set up with another | Price in the campground’s currency |
A 400 names the field that failed, for example
{"total_amount": [{"code": "invalid", "detail": "total_amount must equal the sum of line totals (11.50).", "params": {}}]}.
An error in one line is keyed by the line’s position:
{"items": {"0": {"line_total": ["line_total must equal quantity × unit_price (7.00)."]}}}.
A key without pos:write gets 403, a missing or revoked key 401
(Credentials). The other error
shapes are in Errors.
Reconcile
Section titled “Reconcile”GET pos/transactions/{external_ref}/
returns a sale posted through this API, or 404 with {"detail": "not_found"}.
It does not return sales rung up at the campground’s own till. There is no list
of sales: keep your own external_ref log and check the ones you are unsure
about.
Go-live checklist
Section titled “Go-live checklist”- The campground’s administrator created the till key on the External POS system card, with only the scopes you use, and you store the key per campground.
- Every sale has an
external_refthat is stable across retries and unique per sale, without a/. - You treat
200and201alike, and you retry timeouts,5xxand429with the sameexternal_ref, after a pause. - Your arithmetic matches: lines are quantity times unit price, the total is the sum of the lines.
- You send
skuorbarcodeon lines that have an article, and you checked with the campground that its articles exist. - Returns and voids stay at the campground’s own till, and your staff know it.
- You tried a test sale with the campground, and the administrator saw it under recent inbound transactions on the External POS system card.
When the till works, ask for the review: Partner program.
Related
Section titled “Related”- Retrying safely, Errors, Rate limits and Dates, times and money: the guides behind the edge cases above.
- Accounting and ERP: what a ledger can read, which does not include the sales a till posts.
- Credentials: the scope table, and where each key is created.