The product catalog
How merchants, products, calendars and timeslots fit together, and which endpoint answers which question.
The catalog is what your customer browses. It is entirely public โ no token, no session, no
customer โ scoped by the x-campaign-id header and translated by Accept-Language. You can call
every endpoint on this page straight from a browser.
It is also the part of the API with the most endpoints, so it is worth understanding the shape
before wiring any of them up.
The model
flowchart LR
C["Campaign<br/><small>your commercial programme</small>"] --> M["Merchant<br/><small>the venue</small>"]
M --> P["Product<br/><small>a purchasable ticket type</small>"]
P --> D["Calendar day<br/><small>price + stock for one date</small>"]
D --> T["Timeslot<br/><small>entry window on that date</small>"]
| Level | What it is | Customer sees it as |
|---|---|---|
| Campaign | Your programme: catalog slice, pricing, discount mechanism, languages | The storefront itself |
| Merchant | The venue or operator โ a zoo, a park, a museum, a spa | "Amsterdam Zoo" |
| Product | A specific purchasable thing at that merchant | "Adult day ticket" |
| Calendar day | Price, discount and remaining stock for one date | The date picker |
| Timeslot | An entry window within a date | "09:00 โ 10:00" |
Not every level applies to every product. A product can have no calendar at all, and a dated
product can be valid all day with no timeslots. Two flags on the product tell you which shape
you are dealing with, and reading them wrong is the most common cause of a checkout that fails
at the last step โ see Product shapes below.
Which endpoint answers which question
| Question | Endpoint |
|---|---|
| How should this storefront render at all? | GET /campaign/config |
| Which venues are on offer? | GET /merchants |
| The customer typed something | GET /merchants/broadsearch |
| "Near me", or name-weighted results | GET /merchants/search |
| One venue's page | GET /merchants/{id} |
| What does this venue sell? | GET /merchants/{id}/products |
| One product's detail | GET /merchants/{id}/products/{uuid} |
| Which dates, at what price? | GET .../products/{uuid}/calendar |
| Which entry times on that date? | POST /fulfillment/timeslots |
| Index the catalog from my backend | GET /admin/merchants |
Start with the campaign config
GET /campaign/config is the first call your storefront should make.
It tells you which discount experience to render, what a wallet token is worth, the per-ticket
surcharges, and which languages to offer. Fetch it once at boot and cache it.
Hardcoding any of it means a configuration change on our side quietly desynchronises your UI โ the
classic symptom being a voucher field on a campaign that has moved to loyalty.
Product shapes
Two boolean flags on every product decide what your UI must collect before it can create a
checkout.
no_date | requires_timeslot | What to render |
|---|---|---|
false | false | A date picker. The date alone is enough |
false | true | A date picker and a time picker |
true | false | Neither. Submit today's date at checkout |
true | true | Not a combination you should expect โ treat as date-less |
no_date: true means the product has no per-day entries. Hide the date picker; the calendar
endpoint has nothing to return for it.
requires_timeslot: true means a date is not a bookable request on its own โ the venue admits
visitors in timed windows and needs to know which.
back empty, that date is not bookable. Block it and ask for another.
Never substitute a midnight visit time. For a product with
requires_timeslot: falsethe same
empty list means the opposite โ that product is all-day and the date alone is sufficient. The
flag, not the slot list, tells you which case you are in.
Two more flags shape the purchase rather than the picker:
physical_productโ something gets shipped, so checkout needs a full address. You do not
have to derive this: create checkout returnsrequires_shipping_address
authoritatively. Use the product flag only for a preview like "shipped to your home".sold_outโ the product sells on dates and no purchasable date remains. Show it
unavailable; itsbest_*price fields and availability dates are omitted, so do not read their
absence as โฌ0.
Prices: which number to show where
The catalog carries several prices because different screens need different ones.
| Screen | Field | From |
|---|---|---|
| Merchant tile โ "from โฌ18.50" | best_effective_price + best_list_price | merchant |
| Merchant badge โ "up to 30% off" | max_discount | merchant |
| Product row โ "from โฌ18.50" | best_effective_price, best_list_price, best_discount_* | product |
| Date picker โ one day's price | effective_price + list_price | calendar |
| Order summary | totals | create checkout |
The best_* family answers "from โฌX" across a product's remaining bookable days, computed
stock-aware so a sold-out cheap day never sets the headline. On merchants, the same pair describes
the deepest-discounted product the venue offers, and both fields are absent when the merchant has
no discounted product โ design the tile for that case rather than falling back to zero.
Never total catalog prices yourself for the order summary.Surcharges are per ticket, and discounts depend on the customer's wallet, vouchers and
memberships.totals.grand_totalfrom create checkout is the only
authoritative figure, and it is the one the customer is charged. Catalog prices are for browsing.
All money is {"value": "12.50", "currency": "EUR"} โ a decimal string. Parse it into a
decimal type, never a float.
Searching
Two endpoints, and the difference is worth getting right.
Broad search is the forgiving one for a search box: full-text across
name, alias and description, tolerant of imprecise input. It casts wide.
Custom search is the precise one: hits on the merchant's name
outrank hits in its description, and it can filter by geo-distance and return
distance_meters per result. Use it for name-weighted results, "near me", or both at once.
Always sendlimiton either endpoint. Without it, every match is returned in one response โfine for a script, slow for a customer waiting on a spinner.
Debounce your search box by 250โ300 ms and cancel in-flight requests when the query changes.
Caching
Catalog responses are cacheable, and the times differ by how fast the underlying data moves:
| Endpoint | Cacheable for |
|---|---|
| campaign config | 1 hour |
| merchants list, merchant detail | 4 hours |
| both searches | 1 hour |
| products list, product detail | 2 hours |
| product calendar | 5 minutes |
The calendar is the short one because stock moves. Everything else can be an hour stale without
harming anyone; availability cannot.
Responses carry Vary: X-Campaign-Id, Accept-Language. If you put a CDN or shared cache in
front of the API, key on both headers โ otherwise you will serve one campaign's catalog to
another, or Dutch content to French customers.
Indexing the catalog server-side
If you would rather build your own index than call the public endpoints per page view โ a nightly
crawl into your own search, or a cached catalog behind your app โ
GET /admin/merchants is the same merchant payload authenticated
server-to-server. Your credentials carry the campaign, so there is no x-campaign-id header to
send.
It needs funtrips/catalog.read on your S2S client. Products, calendars and timeslots have no S2S
equivalent: read those from the public endpoints, which need no credential at all.
When you cache the catalog on your side, keep the calendar out of it or give it a short life.
Merchant and product metadata is safe to hold for hours; availability is not, and a stale calendar
sells dates that are gone.
A typical storefront
- Boot โ
GET /campaign/config, cached for the session. - Browse โ
GET /merchants, paged; or a search endpoint when the
customer types. - Venue page โ
GET /merchants/{id}and
GET /merchants/{id}/productsin parallel. - Product page โ
GET .../calendarfor the month on screen. Its
product wrapper is deliberately slim, so take localised text andage_groupfrom
the product. - Date chosen โ if
requires_timeslot,POST /fulfillment/timeslots,
and pass the chosen slot'sentry_fromthrough verbatim as the checkoutdate. - Sell โ The checkout lifecycle.
Related
- Campaign context and language โ the two headers every call needs
- The checkout lifecycle โ what happens after the customer chooses
- Pagination โ cursors on the list and search endpoints
Updated about 2 hours ago
