Changelog
Changes to the public API (/v1/public/*), newest first. How changes are made, and what counts as breaking, is in Versioning and changes.
Unreleased
Section titled “Unreleased”-
Sign-in links that survive a mail scanner (launch plan T2-6, additive):
POST /v1/public/auth/checktakes a sign-in link’stokenand answersusable, whether/v1/public/auth/verifywould sign the traveller in now, without spending it. The page a link opens should call it on load and show a “Sign in” button (or “this link has expired”), and send the token to/v1/public/auth/verifyonly when the button is pressed: Microsoft 365 Safe Links and the like open links before the traveller does, and a page that verified on load gave them the one use./v1/public/auth/verifyis unchanged. -
Requests wait on the seller before the money (launch gate TRAV-2 and TRAV-3, additive): on a booking that needs the seller’s answer and is paid by transfer or link,
payment.due_atis null until the seller accepts it; the traveller is asked to pay then, and the booking’spending_expires_atmoves to that due date.POST /v1/public/quotesandPOST /v1/public/holdsrefuse a start no payment method can be paid before with the newQuoteRefusedcodePaymentWindowClosed(details.offered,details.earliest_start,details.pay_before_start_hours), where checkout used to dead-end onPaymentMethodRequired. A request declined because its seller left the marketplace carries thereject_codeseller_unavailable. -
Compressed answers (launch gate, PERF-15, additive): a JSON or text answer of 1,024 bytes or more comes as Brotli or gzip when your
Accept-Encodingtakes one (Content-Encodingsays which;VarynamesAccept-Encoding). Most HTTP clients ask and decode on their own; one that sends noAccept-Encodinggets the plain body as before. A catalog read’s weakETagis the same whatever the encoding. -
The marketplace’s own keys (launch gate, SITE-3): the keys of the platform’s marketplace storefront carry no per-minute limit and no
RateLimit-*headers unless the platform setsMARKETPLACE_KEY_RATE_LIMIT. Sellers’ keys are limited as before; their counts are now written to the key every two seconds rather than with every call, solast_used_atandRateLimit-Remainingacross API instances can lag by that much. -
Pay on arrival in a currency the seller accepts (launch gate MONEY-6, additive): a booking’s
payment.on_arrivalis now in the first currency the seller’s pay-on-arrival method accepts when the booking’s own currency isn’t one of them, converted through US dollars at the booking’s rate and the rate in force on the day it was made. Read itscurrency; it was always the booking’s before. Show the booking’s total beside it as the reference. -
503 when the API is overloaded (launch review PERF-5): a call that finds every database connection busy for 3 seconds answers
503 Service Unavailableas a problem with codeOverloadedandRetry-After: 1, where it waited without limit before. Retry it after the header’s seconds, as you would a 429. -
Which language a page is written in (SEO-13, additive):
GET /v1/public/placesgainscontent_localeon each place, the language its name and summary are in (localewhen the library translated them, elseen), andGET /v1/public/pagesandGET /v1/public/pages/{slug}gainlocales, the storefront’s languages the page is written in. A site can then make a page its own in a language only where its text is in it, and point the others to that page. -
Where a booking came from, on its events (issue #532, additive): the
booking.requestedandbooking.confirmedwebhook payloads gainattribution, the campaign tags, ad click ids and referrer the site sent with the hold, or null for none, so a connected system can credit the campaign without reading the booking again. -
A site’s own tags (issue #950, ADR-0032, additive):
GET /v1/public/storefrontgainsintegrations, the analytics and advertising tags its owner set for this storefront:gtm_container_id,ga4_measurement_id,google_ads(conversion_idand thepurchase_label,lead_labelandcontact_labelconversions),meta_pixel_id,tiktok_pixel_id,microsoft_uet_id(each null when unset),consent(opt_in,opt_in_strictoropt_out) andenhanced_conversions. Load only these on your site, ask consent first asconsentsays, and send the events in docs/specs/site-tags.md, section 3. A hold’sattributionalso takes the ad click idsgclid,gbraid,wbraid,fbclid,msclkidandttclid(up to 500 characters each). -
The cookie statement and the Tour Pingo promise (launch critique LC-15 and LC-30, additive):
GET /v1/public/storefrontgainscookies_url, the storefront’s cookie statement beside its terms and privacy policy (null when it has none), andpromise: on a marketplace,report_hours, how long after a trip’s start a traveller has to report that nobody came, andpayer, who pays a refund support agrees (sellerorplatform); null on a seller’s own site. Word a promise only from these, never from hours of your own. -
Priced trips first on a marketplace (launch critique LC-18, decision D-15): on a marketplace,
GET /v1/public/productsputs the trips with a price shown before those that ask for one in every order it makes: its own order (priced with a photo, priced without, then asking), a search withq(priced matches first, each part best match first), the other sorts among equals, the home’s rail after the featured trips (home=true) and a collection’s trips (collection=), each part in its own order. It is the platform’s setting, on by default (docs/specs/marketplace-ranking.md). A direct storefront’s lists andGET /v1/public/searchare unchanged. Don’t reorder the list on your side. -
When a seller answers, and sellers that need attention (launch critique LC-184 and LC-93, additive):
GET /v1/public/sellers/{seller}gainsanswer_hours, the window the seller is asked to answer within (its own, else the platform’s). Say it when asking for a price only whilereply_hoursis null, as “asked to reply within”, never as how fast the seller is. On a marketplace,GET /v1/public/productsin its own order (sort=recommended, noq) now puts the trips of a seller that needs attention after the others in each part; filters, searches and the other sorts are unchanged. -
The home’s mix and its collection rails (launch critique LC-20 and LC-187, additive): on a marketplace,
GET /v1/public/products?home=truenow mixes the trips after the featured ones across sellers and countries (docs/specs/marketplace-home.md, section 6) instead of newest first, andGET /v1/public/collectionsgainshome_rails, how many of the collections the home shows as rails (the platform’s setting, 3 by default; 0 on a direct storefront). Show that many rather than a fixed three. -
Categories for every destination (launch critique LC-52): the platform’s taxonomy gains Wildlife Safaris (
wildlife-safaris), Multi-day Tours (multi-day-tours) and Transfers (transfers), and four categories are renamed (Desert Tours, Nature & Parks, Boat Trips, Trekking & Climbing). Slugs are unchanged, so links and filters keep working; read names fromGET /v1/public/categoriesrather than keeping them. -
Asked before booking the same trip twice (launch critique WEB-48, additive):
POST /v1/public/checkouttakeswarn_if_booked. With it, checkout is refused with422AlreadyBooked(details.bookings, how many), booking nothing, when the lead’s email (any case) already has a pending or confirmed booking for the same option and start. Ask the traveller, then check out again without it to book anyway. The refusal never names the first booking’s ref. Without the field nothing changes. -
Retry protection on on-request checkouts (ADR-0010, additive):
POST /v1/public/checkouttakes anIdempotency-Keyheader. For an on-request option checked out with its selection, the same key and request answer with the booking the key first made (with nobooking_token), and the key with another request is a409. A hold needs none: checking the same hold out again already changes nothing. Keys are kept per storefront, shared with the holds’. -
Ask-for-price days on the calendar (issue #762): a trip’s options say
ask_for_price(additive), true when travellers ask the seller for a price instead of booking at one.GET /v1/public/options/{optionId}/calendarfor such an option now shows the days it runs asopen, by its availability rules, closed and blocked days and cutoffs, where every day readclosedbefore for want of a price, so the calendar agrees with the trip list’sdateandmonth. Such an option still has no quote: send the traveller’s day with an enquiry. -
Private or shared, hotel pickup, rating and days (issue #763, additive):
GET /v1/public/productstakesstyle(private,sharedor both: the main option’s kind),pickup=hotel(an option on sale picks travellers up where they stay) andmin_rating(the least score on the seller’s own site, out of 5; unrated trips are left out).durationalso takes a multi-day trip’s days:days_2_3,days_4_7anddays_8_plus, which widen with the other lengths. Withfacets=true,facetsgainsstyle,pickupandrating(4.5and4, each counting the trips rated that or better), anddurationlists the days buckets after the lengths; a multi-day trip counts inmulti_dayand in its days. -
The recommended order on every trip list (issue #757): a
GET /v1/public/productsasked for nothing but where, a template or a category now comes in therecommendedorder too, the trips with a shown price and a photo first, as the contract says, where it came newest first before (an order change, no field changes). Itsnext_cursoris the same kind as every other list’s, so a cursor from before this change is refused with422(cursor_not_this_list); start again from the first page. A list looks through at most 1000 trips, as the filtered list always has. -
Trips by date or month (issue #762, additive):
GET /v1/public/productstakesdate(YYYY-MM-DD) andmonth(YYYY-MM), each in the trip’s own time zone: only the trips with a start the group (travellers, else one) can book on that day, or on a day of that month from today on: open and not blocked, before its cutoff, with places for the whole group, and priced for the day, as the option’s calendar shows it; an option that asks for a price counts on the days its own rules run. They narrowtotaland thefacetscounts as the other filters do. With both,dateis used. A date or month that isn’t real is refused (422,InvalidInput). Nothing changes without them. -
A price range, highest-first orders, and priced trips first (issue #757):
GET /v1/public/productstakesmin_price(additive), the least one traveller pays, read asmax_priceis; a trip with no such price is left out.sorttakesprice_desc(highest first) andduration_desc(longest first), unpriced and unknown last (additive).recommendedwith noq, when the list is asked for prices, counts or the search page’s filters, now puts the trips with a shown price and a photo first, then the rest, each part newest first (an order change, no field changes). -
Where a trip stands on the marketplace, beside its checklist (issue #510, additive, operator API):
GET /v1/products/{id}/checklist, and the checklistPOST /v1/products/{id}/submitand.../unpublishanswer with, gainlisting: null for a trip never submitted for the marketplace (target: network), else itsstatus(pending,approved,rejectedorwithdrawn),submitted_at,approved_atand thereasonplatform staff gave for a rejection, asPOST /v1/products/{id}/withdrawanswers them. A submit’s or an unpublish’s is the listing as it stands after. Nothing else changes. -
The marketplace’s seller terms (issue #556, operator API, additive):
GET /v1/operator/seller-termsgives the seller terms in force, the version the operator accepted last and whether the ones in force aredue;POST /v1/operator/seller-termswith{ "version" }accepts them, by a person signed in to the portal (409,seller_terms_by_a_person, with an API key;seller_terms_changedfor a version no longer in force;seller_terms_nonewhile there are none). For platform staff,GETandPUT /v1/network/settingsgainseller_terms, and each seller inGET /v1/network/sellersgainsseller_terms_version. -
The storefront’s terms and privacy policy (issue #556, additive):
GET /v1/public/storefrontgainsterms_urlandprivacy_url, the pages travellers read before they book, each a path on the storefront’s own site (/terms) or an address; null when it has none. Link them where travellers book. The operator API sets them with the storefront’s contact (PUT /v1/storefronts/{id}/contact, also additive); a request that leaves them out clears them, as for its other fields. -
The storefront lists the sites it allows (issue #533, additive):
GET /v1/public/storefrontgainsallowed_origins, the sites a browser may call the API from with the key (issue #525), as Developers, Website keys in the portal sets them. The booking embed may be shown only on their pages. Empty, as before, means server to server only. -
A trip that asks for a price goes on the marketplace too (ADR-0028, operator API):
POST /v1/products/{id}/submitwithtarget: networkno longer refuses a trip whose options ask for a price (options_ask_for_price), and adding or saving an option withask_for_price: trueis no longer refused while its trip is on the marketplace or waiting for its review (marketplace_needs_price); neither name is sent any more. On the public API, the marketplace’s search and trip pages can now have such a trip, withfrom_price: null, as a seller’s own storefront already could. -
A booked option stays (issue #743, operator API, a new refusal):
DELETE /v1/products/{id}/options/{optionId}on an option that bookings name, whatever their status, is refused with a409whosereasonisoption_bookedand whosedetailshasbookings, the number of them. Nothing is removed. It was a500before. Close the option’s availability to stop selling it. -
A place’s longer text in the library, and in a seller’s suggestion (issue #644, additive, operator API):
POST /v1/placestakes an optionalsummary(a line, up to 500 characters) andbody(what your own site says about the place, up to 10,000 characters), so a place you suggest arrives with its text; platform staff read it before they decide. Platform staff write a place’sbodyin the library and in each language, and a place’s page (GET /v1/public/places/{place}) shows it asbody, as before. Nothing else changes. -
A price for each group size in one read (issue #720, additive):
GET /v1/public/options/{optionId}/group-pricesanswers what each group size pays on one start (start, else the first open start ofmonth, this month by default), from the smallest the option allows up to its own limit, orup_to(1 to 50; 10 when the option sets none). Each ofgroupsis a quote for that many of the option’s main unit (the adult, else another age band, else a group or vehicle), with itstotal,each,unit_priceandconfirmation; a trip sold by room is quoted the cheapest way each group can sleep.unitsprices each other age band beside the smallest group, andpolicyis the cancellation policy. A size the quote engine refuses is left out. In place of a quote per size, so an operator site’s price table costs one call. -
Every
422 InvalidInputa check makes names its problems (issue #722, additive): the problem gainsproblems, one entry for each thing found wrong, in order, each withreason(its name in snake_case, e.g.too_many_photos,rule_ends_before_start),fieldwhere it is about one (a path into what was sent, e.g.start),detail(that problem in English) anddetails(what it is worded from, e.g.{ "rule": 2 }).detailon the problem still says them all, joined by “; “. A body the contract can’t decode has an emptyproblems. A name never changes; new ones are added. -
A departure’s events for webhooks (issue #682, additive, operator API): an endpoint’s
eventscan now includebooking.messaged, posted once for each booking when staff message everyone on a departure, anddeparture.cancelled, posted once for each departure called off, with the call’s id, reason and note, the start offered instead, and what happened to each booking. Their payloads are in README.md, “Webhooks”. Nothing else changes. -
Every
409 Conflictnames its refusal (issue #719, additive): the problem gainsreason, the refusal’s name in snake_case (e.g.booking_not_confirmed,marketplace_needs_price), anddetails, what it is worded from (e.g.{ "ref": "TP-7KQM-2XWD", "booking_status": "cancelled" }).detailkeeps its English sentence. Branch onreason, or word the refusal in your visitor’s language from it anddetails. A name never changes; new ones are added. -
Rate limits per key (issue #530): each key may make 600 calls a minute (publishable) or 120 (secret) by default. Every answer to a key’s call carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset; a call over the limit is429with codeRateLimited,retry_after_secondsand aRetry-Afterheader. Additive:429is a new response on every operation a key can call, and a client within its limit sees no difference but the headers. -
Price requests in the traveller’s account (issue #546, additive):
GET /v1/public/me/price-requests, with the traveller’s session, lists the price requests sent through this storefront with the email they proved, signed in or not, newest first (up to 50). A price request isPOST /v1/public/enquirieswithkind: productandtravellers. Each has its reference, status, trip and seller, dates, group and, once the seller sent a price, the offer’s id, state and link token, whichGET /v1/public/offers/{id}?token=opens. The message and phone are never listed. -
“Refund me” on a moved booking (issue #519, additive): when a seller calls a departure off and moves its bookings to a later start,
GET /v1/public/bookings/{ref}/refund-insteadanswers why, the start called off and the one moved to, and the refund (everything paid and not yet refunded);POSTon the same path takes it, until the new start: the booking is cancelled by the seller with everything paid refunded, and the booking is answered asGET /v1/public/bookings/{ref}answers it. Both need the booking’s signed link (token) or its signed-in traveller;404when nothing is offered. -
Where a booking was sold, and how its price is shared (issue #491, additive, operator API):
GET /v1/bookings/{ref}and the other staff answers with one booking gainsold_through, the name of the storefront it was sold on (one of the operator’s own sites, or the marketplace that sold it), andsplit, the operator’s share, the marketplace’s commission and a reseller’s margin in USD, as worked out when it was booked. -
Saved trips in the traveller’s account (issue #546, additive):
GET /v1/public/me/saved-trips,POSTto save one trip or a guest’s whole list once they sign in ({ trips: [{ trip, seller }] }, newest first, up to 200), andDELETE .../saved-trips/{productId}, with the traveller’s session. Each storefront keeps its own list, and only trips still on sale there are listed, each with its slug and seller. -
Rooms on trips of several days (issue #592, additive for trips that don’t use them): a trip’s option can list
room_typesonGET /v1/public/products/{product}, each with how many sleep in it, whether a solo traveller may share it, and itssupplementper traveller on top of the price per person sharing. For such an option, quotes, holds and on-request checkout needrooms([{room_type_id, qty}]; beds for a shared room) that sleep everyone who takes a seat, else422 RoomingInvalidwithdetails.reasonrooms_required,rooms_dont_match_travellers,unknown_room_typeornot_enough_rooms. Quote lines of kindroomprice them, and a quote’s newpairingsays a solo traveller in a shared room will be paired later. An offer’s price already has the rooms in it. Options without room types are unchanged. -
Signed in, a traveller acts on their own booking (issue #545, additive): the travellers’ details form (
GET /v1/public/bookings/{ref}/travellers,PUT .../travellers/{position}), a traveller’s link (POST .../travellers/{position}/link) and the receipt upload (POST .../receipt) now also open with the session of the traveller who owns the booking, as reading, the refund preview and cancelling already did. The email and the token work as before. -
Fixed: a marketplace booking’s details, receipt and contact fix (issue #545): on a marketplace storefront, which sells other sellers’ trips, the travellers’ details form, a traveller’s link, the receipt upload and the contact changes (
POST .../contact-changes,.../confirm) answered404for every booking. They now find it, in its seller’s books, as reading and cancelling did. -
Where a booking came from (issue #532, additive):
POST /v1/public/holdsandPOST /v1/public/checkouttake an optionalattributionwithutm_source,utm_medium,utm_campaign,utm_term,utm_contentandreferrer, as your site saw them when the traveller landed. Checkout’s replaces the hold’s; left out, the hold’s stays. The seller sees them on the booking (attributiononGET /v1/bookings/{ref}); the traveller’s booking never carries them. Each tag is at most 200 characters, the referrer 500; empty ones are dropped. A hold’sIdempotency-Keydoesn’t compare them: a repeat with other tags answers with the first hold and its tags. -
Cache headers (issue #531, additive): catalog reads (
/storefront,/page,/pages,/menus,/redirect,/products,/products/{product},/search,/cities,/sellers,/categories,/placesand/places/{place}) answer with a weakETag,Cache-Control: private, max-age=60andVary: Authorization, tp-publishable-key. Sent back asIf-None-Match, an unchanged answer is304with no body. Every other public answer isCache-Control: no-store. A client that ignores the headers sees no difference. -
Webhooks (issue #522, additive, operator API): a seller adds HTTPS endpoints with
POST /v1/webhook-endpoints(webhook:manage, from a staff session or a secret key given it) and picks the event types each gets: bookings, enquiries, payments, trips and pages among them. Each of the seller’s own events of those types is posted as JSON, signed inTourPingo-Signaturewith the endpoint’swhsec_…secret (shown once), withTourPingo-Event-Idto drop duplicates by. A 2xx within 10 seconds succeeds; anything else is retried after 1, 5 and 30 minutes, then 2, 6, 12 and 24 hours, and an endpoint with no success for 3 days is turned off and its managers are emailed. Both are the seller’s settings (/v1/webhook-settings). Deliveries are kept 30 days (GET /v1/webhook-deliveries) and can be sent again; a test event, and a secret roll that keeps the old secret signing for 24 hours, are one call each. How to verify a delivery is in README.md, “Webhooks”. -
A TypeScript client (issue #486):
@tourpingo/clientinpackages/client, generated fromopenapi.yaml, with every schema and operation typed and a smallfetchclient that answers with the data or the problem without throwing. README.md, “The TypeScript client”, says how to use it. Nothing changes in the API. -
Collections on the marketplace home (issue #603, additive):
GET /v1/public/collectionslists, on a marketplace, the collections of trips tourpingo’s staff put together that show today, in their order, each with itsslug, itsnameinlocalewhere written, and itscontent_locale.collection=with a slug onGET /v1/public/productslists that collection’s trips in its order. Both are empty on a direct storefront. Show each as a rail of its own after the home’s trips, the first three with trips. -
Places’ names in other languages change from the portal (issue #602, no change to the contract): tourpingo’s staff now write a place’s name and line in each storefront language by hand, or remove them, so the
name,summaryandcontent_localeof a place inGET /v1/public/placesand on trips can change for a locale without the trip changing. The trips that reach the place are announced as changed. Don’t keep a place’s name in another language past a trip’s refresh. -
Categories change from the portal (issue #602, no change to the contract): tourpingo’s staff now add, rename, translate, order and retire categories. A retired category’s trips move to another, so it leaves
GET /v1/public/categories, andcategory=with its slug finds no trips. Read the categories from that list rather than keeping slugs in code; a slug itself never changes. -
A merged place’s old address (issue #602, additive): tourpingo can merge a duplicate place into the place it repeats.
GET /v1/public/places/{place}with the merged place’s slug then answers with the place it was merged into, whoseslugdiffers from the one asked for: redirect to that slug (permanently, for search engines), as tourpingo’s storefront does. The trips that started or stopped at the merged place are listed at the other one, and are announced as changed. -
The marketplace home’s order (issue #603, additive):
home=trueonGET /v1/public/productslists a marketplace’s trips as its home’s trips rail shows them: the trips tourpingo features first, in their order, then the rest in the list’s order, without the trips hidden from the home. Those stay in every other list and in search. A direct storefront’s list is the same with or without it. Use it for a home page’s trips, not for search. -
POST /v1/public/holdstakes an optionalIdempotency-Keyheader (issue #461). The same key with the same request, on the same storefront, answers with the first hold as it stands instead of holding the places again; the key with a different request is409 Conflict. Send a new key for each hold the traveller means to make, e.g. one per click of “Book”. -
Request ids on problems (issue #526, additive): every answer now carries
x-request-id, problems included (before, only successful answers did). Send your ownx-request-id(8 to 64 letters, digits,-or_) to find a call in the operator’s request log, which keeps each call made with a key for 30 days, with no bodies or query values (README.md, “Request log”). -
Bookings staff make for a traveller (issue #515, additive): a seller’s staff can now book on a traveller’s behalf through one of the seller’s own sites (
POST /v1/bookings, staff only), so a public booking may show:- a quote line of
kind: "adjustment": a discount (negativetotal) or surcharge staff added, labelled with its reason,qty1,pay_atupfront,refadjustment-1,adjustment-2, …; the quote’s own lines are unchanged andtotalsinclude it; - a
payment.methodofcash,cardorother(besidebank_transfer,pay_on_arrivalandpayment_link) for money staff took already;statusis thenpaid.
A client that switches over either field should show an unknown value as it is rather than fail.
- a quote line of
-
Every operation, parameter and field is described (issue #528): what it is, its unit (minor units, ISO codes, time zones) and when it is null. Documentation only: no field, type or rule changed. The spec also names its server (
/, the API that serves it) and describes each group of operations. -
Saving a traveller’s card keeps what a read masks (issue #616):
PUT /v1/public/bookings/{ref}/travellers/{position}, and staff’sPUT /v1/bookings/{ref}/travellers/{position}, no longer erase the date of birth, passport number or medical note that a save leaves out. Reads mask those three, so a storefront can’t send them back. Left out or null, each keeps what the card holds; an empty string erases it. A masked value sent back as it was read (••••-••-••,•••• 4821,•••••) is refused with 422. Every other field is still replaced as sent. Until now, leaving one out or sending null erased it. -
Currencies follow the exchange rates (issue #555):
GET /v1/public/storefrontlists only the storefront’s currencies that can be quoted now, and itsdisplay_currencyis one of them (else the first that is, else USD). A currency drops out while it has no rate, or its feed rate is past the platform’s age limit (5 days by default); quotes in it answer422 CurrencyNotEnabled. Read the list from this call rather than storing it. -
The trip list counts its trips by city (issue #548, additive):
facets.cityonGET /v1/public/products?facets=true, each city the trips start in, ascitytakes it, with how many of them start there, most first. Withqit says where the trips with those words are, which the storefronts’ Where lists offer as the traveller types. -
Typos forgiven in the trip list’s words (issue #548):
qonGET /v1/public/productsnow goes through search.wadi shapfinds whatwadi shabfinds, a trip’s places, categories and city count as well as its title and summary, and withsort=recommended(the default) the best matches come first. Every filter,facetsand the prices work as before, and a trip with the words in its title or summary is found even before search has indexed it. When search is down, the list finds the words in the title or summary alone, never a 503. A list withqpages as the search page’s lists do: pass back itsnext_cursor, as before. -
Why a request was declined (issue #547, additive):
rejection_reasonon the booking (GET /v1/public/bookings/{ref}and checkout’s answer). For a rejected booking it is the reason the seller wrote for the traveller, as their email gives it; null for every other status, a cancelled one included. -
A seller’s price on a price request (issue #543, additive):
GET /v1/public/offers/{id}?token=opens the offer a seller sent on a price request through this storefront, by its signed link’s token: its trip, option, start, group, what it includes, its last day and, while it is open, its quote.POST /v1/public/offers/{id}/hold?token=holds it at its price for checkout, which books it as any hold. Quote lines can now be of kindoffer: the seller’s price for the group, with the units’ lines at zero andincluded. -
A cancelled booking says when and of what (issue #539): a booking’s
cancellationnow carriesat, when it was cancelled, andpaid, what the traveller had paid upfront, whichrefundis a share of.paidis zero when nothing was paid (a transfer or link never paid, or a booking paid on the day), so nothing is refunded. Show the refund after the cancel as the preview showed it before. -
Change a hold’s extras and pickup (issue #538, additive):
PATCH /v1/public/holds/{ref}withextrasandpickup, each in place of the hold’s own, re-quotes the hold for the same start, group and currency before checkout. Its places and its time left stay. Refused like a quote, leaving the hold as it was;HoldExpiredonce its time is up; 409 once it is checked out. -
A booking’s
paymentcarrieslink(issue #540): for a payment link, the seller’s page to pay on, which is the storefront’s own payment page from checkout, or else the link the seller’s staff add for the booking’s amount. It is null until then, and for the other methods. Show it to the traveller as the seller’s, never yours. Additive. -
A suspended seller’s own sites stop taking bookings (issue #570, a new refusal):
POST /v1/public/holdsandPOST /v1/public/checkouton a site whose seller tourpingo has suspended answer422with the codeSellerUnavailable, while the marketplace’s setting says so (on by default). Bookings already made keep working: reading them, paying, uploading a receipt, the travellers’ details and cancelling. Show thedetailand offer no booking until the seller is reinstated. -
A place’s photos (issue #459, additive):
photoson each place ofGET /v1/public/places(up to 4) and onGET /v1/public/places/{place}(up to 24), each{ url, alt, width, height }as a trip’s photos are. They are the photos of the storefront’s trips that their sellers tagged with the place (a trip photo’splace_id), the trips’ covers first; an empty list when there are none, never a stand-in. -
Browsers can call
/v1/public/*from the origins a storefront lists (issue #525): the API now answers CORS preflights and echoes a listedOrigin. Calls from a server, with noOrigin, are unchanged. See README.md, “Website keys: browsers, allowed origins and rotation”. -
A new error code,
403 OriginNotAllowed, with the refusedorigin, for a browser on an origin the key’s storefront doesn’t list. Before, such calls failed in the browser with no CORS answer at all. -
A rotated key keeps working until its
expires_at(24 hours after the rotation by default), then gets401 Unauthenticatedlike any unknown key.
2026-10-01: the contract is published
Section titled “2026-10-01: the contract is published”docs/api/openapi.yamlis now the generated spec, the same as/openapi.json. It replacesopenapi.draft.yaml, which was never served.- For clients built on the draft, the main differences are:
- Booking is by holds, not carts.
POST /v1/public/holdswith the quote’s selection,GET /v1/public/holds/{ref}, thenPOST /v1/public/checkoutwith the hold’sref(or an on-request checkout). There are no/cartsroutes. - The calendar is
GET /v1/public/options/{optionId}/calendar?month=(it was/availability), with no price per day; price a day with a quote. - Errors carry the refusal’s fields under
details; schema failures are422 InvalidInput, and a missing or wrong key is401 Unauthenticated. - No
Idempotency-Keyon public routes yet. Checkout is idempotent by itself (the same hold and lead change nothing); a hold is not.
- Booking is by holds, not carts.