Skip to content

Quickstart: storefront

This walkthrough makes the same calls the hosted booking widget makes. You need two things from the operator whose products you are selling: their tenant id and a publishable key (pk_…). The publishable key can only browse the catalog, read availability and open a hold; it cannot read bookings or move money, so it is safe to ship in a browser bundle.

Terminal window
export QS_BASE=https://quikstik-core.fly.dev
export QS_TENANT_ID=00000000-0000-0000-0000-000000000001 # the operator's tenant
export QS_PUBLISHABLE_KEY=pk_live_… # their storefront key

Every call below sends the same two headers:

x-api-key: $QS_PUBLISHABLE_KEY
x-tenant-id: $QS_TENANT_ID
Terminal window
curl -s "$QS_BASE/offerings" \
-H "x-api-key: $QS_PUBLISHABLE_KEY" -H "x-tenant-id: $QS_TENANT_ID"
{
"items": [
{ "offering_id": "10000000-0000-0000-0000-0000000000a1", "catalog_product_id": "T1",
"display_name": "Carriage Roads Guided E-Bike Tour", "kind": null },
{ "offering_id": "30000000-0000-0000-0000-0000000000a3", "catalog_product_id": "R1",
"display_name": "Carriage Roads Self-Guided E-Bike Rental", "kind": null }
],
"next_cursor": null
}

An offering is a product; what you actually sell is one of its variants. Read the offering to get them:

Terminal window
curl -s "$QS_BASE/offerings/30000000-0000-0000-0000-0000000000a3" \
-H "x-api-key: $QS_PUBLISHABLE_KEY" -H "x-tenant-id: $QS_TENANT_ID"
{
"offering_id": "30000000-0000-0000-0000-0000000000a3",
"catalog_product_id": "R1",
"display_name": "Carriage Roads Self-Guided E-Bike Rental",
"slug": "ebike-rentals-carriage-roads",
"variants": [
{ "variant_id": "30000000-0000-0000-0000-0000000000b3",
"display_name": "Carriage Roads Self-Guided E-Bike Rental", "sku": null }
]
}

GET /offerings/{offering_id}/variants/{variant_id} returns the variant in full: capacity, duration, meeting point, add-ons and the participant rules text.

Availability is computed when you ask, from the tenant’s capacity chain (the departure, and every shared constraint above it such as a fleet or a permit hour). Ask for a variant and a window; you get one line per departure in the window.

Terminal window
curl -s "$QS_BASE/availability/snapshot-requests" \
-H "x-api-key: $QS_PUBLISHABLE_KEY" -H "x-tenant-id: $QS_TENANT_ID" \
-H "content-type: application/json" \
-d '{
"offering_variant_id": "30000000-0000-0000-0000-0000000000b3",
"window": { "start": "2026-11-05T00:00:00Z", "end": "2026-11-06T00:00:00Z" },
"quantity": 2
}'
{
"snapshot_id": "17007c51-5fbb-46c1-b0a6-fbc2e0662802",
"generated_at_ms": 1793542016783,
"window": { "start": "2026-11-05T00:00:00Z", "end": "2026-11-06T00:00:00Z" },
"lines": [
{ "offering_variant_id": "30000000-0000-0000-0000-0000000000b3",
"start_time": "2026-11-05T13:00:00Z", "status": "sold_out",
"available_quantity": 0, "basis": { "chain_remaining": 0, "withheld": 0, "offered": 0 } },
{ "offering_variant_id": "30000000-0000-0000-0000-0000000000b3",
"start_time": "2026-11-05T14:00:00Z", "status": "available",
"available_quantity": 6, "basis": { "chain_remaining": 6, "withheld": 0, "offered": 6 } }
]
}

Read status and available_quantity to render the picker. basis tells you why the number is what it is: chain_remaining is the tightest limit anywhere up the capacity chain, withheld is seats reserved for other sales channels, and offered = chain_remaining − withheld. An empty lines array means no departures are scheduled in that window.

A snapshot is advisory. It does not hold anything; the hold is the next step.

A booking intent binds a variant and a start time to a departure and records what the customer wants. Create it as soon as the customer picks a departure, before you collect card details.

Terminal window
curl -s "$QS_BASE/booking-intents" \
-H "x-api-key: $QS_PUBLISHABLE_KEY" -H "x-tenant-id: $QS_TENANT_ID" \
-H "content-type: application/json" \
-d '{
"offering_variant_id": "30000000-0000-0000-0000-0000000000b3",
"start_time": "2026-11-05T14:00:00Z",
"quantity": 2,
"participants": [
{ "participant_id": "'$(uuidgen)'", "display_name": "Ana Rivera", "birth_date": "1988-04-02" },
{ "participant_id": "'$(uuidgen)'", "display_name": "Mike Rivera", "birth_date": null }
]
}'

Each participant carries a participant_id you mint (any UUID); it is how later calls refer to that rider. The 201 response is the intent, including booking_intent_id and the departure_id it bound to. Keep the id: the purchase settles this hold, and the server commits capacity against the bound departure, so your client never names a capacity pool and cannot mis-key one.

Tokenise the card with the processor’s client library (for Stripe, a PaymentMethod id from Stripe.js) and send the token with the hold. Quikstik commits the seats, resolves net, tax and gross from the governed catalog, then charges.

Mint an obligation_ref (a UUID) for the purchase. It names the booking’s money obligation from this moment on — balances, refunds and the ledger are all addressed by it — so store it with your order. Send a fresh x-idempotency-key per purchase attempt and reuse it when you retry that same attempt.

Terminal window
export OBLIGATION_REF=$(uuidgen)
export IDEMPOTENCY_KEY=$(uuidgen)
curl -s "$QS_BASE/storefront/purchases" \
-H "x-api-key: $QS_PUBLISHABLE_KEY" -H "x-tenant-id: $QS_TENANT_ID" \
-H "x-idempotency-key: $IDEMPOTENCY_KEY" \
-H "content-type: application/json" \
-d '{
"booking_intent_id": "<from step 3>",
"obligation_ref": "'$OBLIGATION_REF'",
"offering_id": "30000000-0000-0000-0000-0000000000a3",
"offering_variant_id": "30000000-0000-0000-0000-0000000000b3",
"units": 2,
"demand_class": "default",
"settlement": "full",
"effective_year": 2026, "effective_month": 11, "effective_day": 5,
"provider": "stripe",
"instrument_token": "pm_…"
}'

units is the number of seats to commit (normally the participant count). settlement is full or, where the tenant’s policy allows a deposit, deposit — the balance then becomes collectable later through a balance link. demand_class is the pricing lane; default is the direct storefront lane. For multi-day rentals add rental_days.

{
"outbox_id": "…",
"processor_ref": "ch_…",
"net_minor": 27800,
"tax_minor": 1530,
"gross_minor": 29330
}

Amounts are integer minor units (cents). Outcomes you must handle:

Status Reason Meaning
200 — Seats committed and card settled. Show the confirmation.
409 sold_out / capacity_full The chain could not hold the seats. Nothing was charged. Re-read availability.
422 rail_refused The processor declined. Nothing is committed. Ask for another card.
402 processor_not_configured The operator has not connected a processor. Nothing was charged.
400 idempotency_key_required You forgot x-idempotency-key.

Retrying a timed-out purchase with the same idempotency key is safe: you get the original result, not a second charge.

The booking’s confirmation email leaves through Quikstik, with the operator in copy. The operator sees the booking in their app and on the day’s manifest. If the customer needs to cancel or move, they do it from the link in their confirmation (a guest link), and the refund fraction comes from the tenant’s cancellation policy, not from anything the storefront decides.

From here: Authentication if you are building for the operator side, or Conventions for headers, errors and pagination.