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”-
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.