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.

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