Skip to content

Changelog

Changes to the public API (/v1/public/*), newest first. How changes are made, and what counts as breaking, is in Versioning and changes.

  • Sign-in links that survive a mail scanner (launch plan T2-6, additive): POST /v1/public/auth/check takes a sign-in link’s token and answers usable, whether /v1/public/auth/verify would 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/verify only 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/verify is 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_at is null until the seller accepts it; the traveller is asked to pay then, and the booking’s pending_expires_at moves to that due date. POST /v1/public/quotes and POST /v1/public/holds refuse a start no payment method can be paid before with the new QuoteRefused code PaymentWindowClosed (details.offered, details.earliest_start, details.pay_before_start_hours), where checkout used to dead-end on PaymentMethodRequired. A request declined because its seller left the marketplace carries the reject_code seller_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-Encoding takes one (Content-Encoding says which; Vary names Accept-Encoding). Most HTTP clients ask and decode on their own; one that sends no Accept-Encoding gets the plain body as before. A catalog read’s weak ETag is 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 sets MARKETPLACE_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, so last_used_at and RateLimit-Remaining across 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_arrival is 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 its currency; 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 Unavailable as a problem with code Overloaded and Retry-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/places gains content_locale on each place, the language its name and summary are in (locale when the library translated them, else en), and GET /v1/public/pages and GET /v1/public/pages/{slug} gain locales, 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.requested and booking.confirmed webhook payloads gain attribution, 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/storefront gains integrations, the analytics and advertising tags its owner set for this storefront: gtm_container_id, ga4_measurement_id, google_ads (conversion_id and the purchase_label, lead_label and contact_label conversions), meta_pixel_id, tiktok_pixel_id, microsoft_uet_id (each null when unset), consent (opt_in, opt_in_strict or opt_out) and enhanced_conversions. Load only these on your site, ask consent first as consent says, and send the events in docs/specs/site-tags.md, section 3. A hold’s attribution also takes the ad click ids gclid, gbraid, wbraid, fbclid, msclkid and ttclid (up to 500 characters each).

  • The cookie statement and the Tour Pingo promise (launch critique LC-15 and LC-30, additive): GET /v1/public/storefront gains cookies_url, the storefront’s cookie statement beside its terms and privacy policy (null when it has none), and promise: on a marketplace, report_hours, how long after a trip’s start a traveller has to report that nobody came, and payer, who pays a refund support agrees (seller or platform); 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/products puts 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 with q (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 and GET /v1/public/search are 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} gains answer_hours, the window the seller is asked to answer within (its own, else the platform’s). Say it when asking for a price only while reply_hours is null, as “asked to reply within”, never as how fast the seller is. On a marketplace, GET /v1/public/products in its own order (sort=recommended, no q) 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=true now mixes the trips after the featured ones across sellers and countries (docs/specs/marketplace-home.md, section 6) instead of newest first, and GET /v1/public/collections gains home_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 from GET /v1/public/categories rather than keeping them.

  • Asked before booking the same trip twice (launch critique WEB-48, additive): POST /v1/public/checkout takes warn_if_booked. With it, checkout is refused with 422 AlreadyBooked (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/checkout takes an Idempotency-Key header. For an on-request option checked out with its selection, the same key and request answer with the booking the key first made (with no booking_token), and the key with another request is a 409. 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}/calendar for such an option now shows the days it runs as open, by its availability rules, closed and blocked days and cutoffs, where every day read closed before for want of a price, so the calendar agrees with the trip list’s date and month. 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/products takes style (private, shared or both: the main option’s kind), pickup=hotel (an option on sale picks travellers up where they stay) and min_rating (the least score on the seller’s own site, out of 5; unrated trips are left out). duration also takes a multi-day trip’s days: days_2_3, days_4_7 and days_8_plus, which widen with the other lengths. With facets=true, facets gains style, pickup and rating (4.5 and 4, each counting the trips rated that or better), and duration lists the days buckets after the lengths; a multi-day trip counts in multi_day and in its days.

  • The recommended order on every trip list (issue #757): a GET /v1/public/products asked for nothing but where, a template or a category now comes in the recommended order 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). Its next_cursor is the same kind as every other list’s, so a cursor from before this change is refused with 422 (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/products takes date (YYYY-MM-DD) and month (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 narrow total and the facets counts as the other filters do. With both, date is 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/products takes min_price (additive), the least one traveller pays, read as max_price is; a trip with no such price is left out. sort takes price_desc (highest first) and duration_desc (longest first), unpriced and unknown last (additive). recommended with no q, 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 checklist POST /v1/products/{id}/submit and .../unpublish answer with, gain listing: null for a trip never submitted for the marketplace (target: network), else its status (pending, approved, rejected or withdrawn), submitted_at, approved_at and the reason platform staff gave for a rejection, as POST /v1/products/{id}/withdraw answers 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-terms gives the seller terms in force, the version the operator accepted last and whether the ones in force are due; POST /v1/operator/seller-terms with { "version" } accepts them, by a person signed in to the portal (409, seller_terms_by_a_person, with an API key; seller_terms_changed for a version no longer in force; seller_terms_none while there are none). For platform staff, GET and PUT /v1/network/settings gain seller_terms, and each seller in GET /v1/network/sellers gains seller_terms_version.

  • The storefront’s terms and privacy policy (issue #556, additive): GET /v1/public/storefront gains terms_url and privacy_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/storefront gains allowed_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}/submit with target: network no longer refuses a trip whose options ask for a price (options_ask_for_price), and adding or saving an option with ask_for_price: true is 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, with from_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 a 409 whose reason is option_booked and whose details has bookings, the number of them. Nothing is removed. It was a 500 before. 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/places takes an optional summary (a line, up to 500 characters) and body (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’s body in the library and in each language, and a place’s page (GET /v1/public/places/{place}) shows it as body, as before. Nothing else changes.

  • A price for each group size in one read (issue #720, additive): GET /v1/public/options/{optionId}/group-prices answers what each group size pays on one start (start, else the first open start of month, this month by default), from the smallest the option allows up to its own limit, or up_to (1 to 50; 10 when the option sets none). Each of groups is a quote for that many of the option’s main unit (the adult, else another age band, else a group or vehicle), with its total, each, unit_price and confirmation; a trip sold by room is quoted the cheapest way each group can sleep. units prices each other age band beside the smallest group, and policy is 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 InvalidInput a check makes names its problems (issue #722, additive): the problem gains problems, one entry for each thing found wrong, in order, each with reason (its name in snake_case, e.g. too_many_photos, rule_ends_before_start), field where it is about one (a path into what was sent, e.g. start), detail (that problem in English) and details (what it is worded from, e.g. { "rule": 2 }). detail on the problem still says them all, joined by “; “. A body the contract can’t decode has an empty problems. A name never changes; new ones are added.

  • A departure’s events for webhooks (issue #682, additive, operator API): an endpoint’s events can now include booking.messaged, posted once for each booking when staff message everyone on a departure, and departure.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 Conflict names its refusal (issue #719, additive): the problem gains reason, the refusal’s name in snake_case (e.g. booking_not_confirmed, marketplace_needs_price), and details, what it is worded from (e.g. { "ref": "TP-7KQM-2XWD", "booking_status": "cancelled" }). detail keeps its English sentence. Branch on reason, or word the refusal in your visitor’s language from it and details. 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-Remaining and RateLimit-Reset; a call over the limit is 429 with code RateLimited, retry_after_seconds and a Retry-After header. Additive: 429 is 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 is POST /v1/public/enquiries with kind: product and travellers. 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, which GET /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-instead answers why, the start called off and the one moved to, and the refund (everything paid and not yet refunded); POST on 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 as GET /v1/public/bookings/{ref} answers it. Both need the booking’s signed link (token) or its signed-in traveller; 404 when 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 gain sold_through, the name of the storefront it was sold on (one of the operator’s own sites, or the marketplace that sold it), and split, 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, POST to save one trip or a guest’s whole list once they sign in ({ trips: [{ trip, seller }] }, newest first, up to 200), and DELETE .../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_types on GET /v1/public/products/{product}, each with how many sleep in it, whether a solo traveller may share it, and its supplement per traveller on top of the price per person sharing. For such an option, quotes, holds and on-request checkout need rooms ([{room_type_id, qty}]; beds for a shared room) that sleep everyone who takes a seat, else 422 RoomingInvalid with details.reason rooms_required, rooms_dont_match_travellers, unknown_room_type or not_enough_rooms. Quote lines of kind room price them, and a quote’s new pairing says 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) answered 404 for 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/holds and POST /v1/public/checkout take an optional attribution with utm_source, utm_medium, utm_campaign, utm_term, utm_content and referrer, 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 (attribution on GET /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’s Idempotency-Key doesn’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, /places and /places/{place}) answer with a weak ETag, Cache-Control: private, max-age=60 and Vary: Authorization, tp-publishable-key. Sent back as If-None-Match, an unchanged answer is 304 with no body. Every other public answer is Cache-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 in TourPingo-Signature with the endpoint’s whsec_… secret (shown once), with TourPingo-Event-Id to 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/client in packages/client, generated from openapi.yaml, with every schema and operation typed and a small fetch client 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/collections lists, on a marketplace, the collections of trips tourpingo’s staff put together that show today, in their order, each with its slug, its name in locale where written, and its content_locale. collection= with a slug on GET /v1/public/products lists 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, summary and content_locale of a place in GET /v1/public/places and 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, and category= 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, whose slug differs 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=true on GET /v1/public/products lists 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/holds takes an optional Idempotency-Key header (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 is 409 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 own x-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 (negative total) or surcharge staff added, labelled with its reason, qty 1, pay_at upfront, ref adjustment-1, adjustment-2, …; the quote’s own lines are unchanged and totals include it;
    • a payment.method of cash, card or other (beside bank_transfer, pay_on_arrival and payment_link) for money staff took already; status is then paid.

    A client that switches over either field should show an unknown value as it is rather than fail.

  • 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’s PUT /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/storefront lists only the storefront’s currencies that can be quoted now, and its display_currency is 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 answer 422 CurrencyNotEnabled. Read the list from this call rather than storing it.

  • The trip list counts its trips by city (issue #548, additive): facets.city on GET /v1/public/products?facets=true, each city the trips start in, as city takes it, with how many of them start there, most first. With q it 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): q on GET /v1/public/products now goes through search. wadi shap finds what wadi shab finds, a trip’s places, categories and city count as well as its title and summary, and with sort=recommended (the default) the best matches come first. Every filter, facets and 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 with q pages as the search page’s lists do: pass back its next_cursor, as before.

  • Why a request was declined (issue #547, additive): rejection_reason on 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 kind offer: the seller’s price for the group, with the units’ lines at zero and included.

  • A cancelled booking says when and of what (issue #539): a booking’s cancellation now carries at, when it was cancelled, and paid, what the traveller had paid upfront, which refund is a share of. paid is 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} with extras and pickup, 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; HoldExpired once its time is up; 409 once it is checked out.

  • A booking’s payment carries link (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/holds and POST /v1/public/checkout on a site whose seller tourpingo has suspended answer 422 with the code SellerUnavailable, 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 the detail and offer no booking until the seller is reinstated.

  • A place’s photos (issue #459, additive): photos on each place of GET /v1/public/places (up to 4) and on GET /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’s place_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 listed Origin. Calls from a server, with no Origin, are unchanged. See README.md, “Website keys: browsers, allowed origins and rotation”.

  • A new error code, 403 OriginNotAllowed, with the refused origin, 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 gets 401 Unauthenticated like any unknown key.

  • docs/api/openapi.yaml is now the generated spec, the same as /openapi.json. It replaces openapi.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/holds with the quote’s selection, GET /v1/public/holds/{ref}, then POST /v1/public/checkout with the hold’s ref (or an on-request checkout). There are no /carts routes.
    • 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 are 422 InvalidInput, and a missing or wrong key is 401 Unauthenticated.
    • No Idempotency-Key on public routes yet. Checkout is idempotent by itself (the same hold and lead change nothing); a hold is not.