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>"]
LevelWhat it isCustomer sees it as
CampaignYour programme: catalog slice, pricing, discount mechanism, languagesThe storefront itself
MerchantThe venue or operator โ€” a zoo, a park, a museum, a spa"Amsterdam Zoo"
ProductA specific purchasable thing at that merchant"Adult day ticket"
Calendar dayPrice, discount and remaining stock for one dateThe date picker
TimeslotAn 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

QuestionEndpoint
How should this storefront render at all?GET /campaign/config
Which venues are on offer?GET /merchants
The customer typed somethingGET /merchants/broadsearch
"Near me", or name-weighted resultsGET /merchants/search
One venue's pageGET /merchants/{id}
What does this venue sell?GET /merchants/{id}/products
One product's detailGET /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 backendGET /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_daterequires_timeslotWhat to render
falsefalseA date picker. The date alone is enough
falsetrueA date picker and a time picker
truefalseNeither. Submit today's date at checkout
truetrueNot 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.

โ—

When requires_timeslot is true and the timeslot list for a date comes

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: false the 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 returns requires_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; its best_* 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.

ScreenFieldFrom
Merchant tile โ€” "from โ‚ฌ18.50"best_effective_price + best_list_pricemerchant
Merchant badge โ€” "up to 30% off"max_discountmerchant
Product row โ€” "from โ‚ฌ18.50"best_effective_price, best_list_price, best_discount_*product
Date picker โ€” one day's priceeffective_price + list_pricecalendar
Order summarytotalscreate 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_total from 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 send limit on 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:

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

  1. Boot โ€” GET /campaign/config, cached for the session.
  2. Browse โ€” GET /merchants, paged; or a search endpoint when the
    customer types.
  3. Venue page โ€” GET /merchants/{id} and
    GET /merchants/{id}/products in parallel.
  4. Product page โ€” GET .../calendar for the month on screen. Its
    product wrapper is deliberately slim, so take localised text and age_group from
    the product.
  5. Date chosen โ€” if requires_timeslot, POST /fulfillment/timeslots,
    and pass the chosen slot's entry_from through verbatim as the checkout date.
  6. Sell โ€” The checkout lifecycle.

Related


Did this page help you?