{"openapi":"3.1.0","info":{"title":"TourScanner Tours & Activities API","version":"1.0.0","description":"One API for ~1M tours, tickets and experiences from 14 suppliers: search, live availability, and the full booking lifecycle (hold → pay → confirm → status → cancel / amend) with TourScanner as merchant of record on certified suppliers.\n\n**Auth.** Every endpoint needs an API key, sent as `X-API-Key`. Keys are issued by TourScanner on request: contact api@tourscanner.ai. Without one the API answers 401 `{ \"error\": \"API_KEY_REQUIRED\" }`.\n\n**Retries.** Booking writes accept an `Idempotency-Key` header: a retry with the same key and body replays the original response instead of booking twice.\n\n**Errors** carry `data.code` and `data.retryable`.\n\n**Agents.** An MCP server with the same capabilities is at `https://beta.tourscanner.ai/api/mcp` (Streamable HTTP, same API key as `Authorization: Bearer`, `X-API-Key` or `?api_key=`). On the agent layer (MCP and /api/ai/chat), create_checkout_session returns the tourscanner.io checkout link where the traveller books and pays; cancel and amend run in demo mode. Live bookings through your own checkout go through the /api/booking endpoints below.\n\n**Live environment.** This host is production: `/api/booking/hold` places a real supplier hold and a real Stripe checkout. Contact api@tourscanner.ai for sandbox access.","contact":{"name":"TourScanner API","email":"api@tourscanner.ai","url":"https://tourscanner.ai/docs"}},"servers":[{"url":"https://beta.tourscanner.ai"}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"Search","description":"Destinations and activity catalog"},{"name":"Availability","description":"Live slots and prices from suppliers"},{"name":"Booking","description":"Hold, confirm, status, cancel, amend"},{"name":"Agent","description":"Hosted agent and MCP server"}],"paths":{"/api/search/destinations":{"get":{"tags":["Search"],"operationId":"searchDestinations","summary":"Search cities and attractions by name","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2}},{"name":"lang","in":"query","schema":{"type":"string","example":"en"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":50}}],"responses":{"200":{"description":"Matching cities and points of interest","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/search/activities":{"get":{"tags":["Search"],"operationId":"searchActivities","summary":"Search activities with filters, facets and ranking","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Free-text search"},{"name":"city","in":"query","schema":{"type":"integer"},"description":"City id"},{"name":"city_name","in":"query","schema":{"type":"string"}},{"name":"poi","in":"query","schema":{"type":"integer"},"description":"Attraction id"},{"name":"tags","in":"query","schema":{"type":"string"},"description":"Comma-separated tag slugs"},{"name":"providers","in":"query","schema":{"type":"string"},"description":"Comma-separated provider ids"},{"name":"bookable_only","in":"query","schema":{"type":"boolean"}},{"name":"date","in":"query","schema":{"type":"string","format":"date"},"description":"Only activities available on this date"},{"name":"guide_lang","in":"query","schema":{"type":"string"},"description":"The traveller's language, ISO 639-1 (e.g. it). Tours guided in it rank a little higher on the default sort; nothing is filtered out"},{"name":"min_price","in":"query","schema":{"type":"number"}},{"name":"max_price","in":"query","schema":{"type":"number"}},{"name":"min_rating","in":"query","schema":{"type":"number"}},{"name":"currency","in":"query","schema":{"type":"string","example":"EUR"}},{"name":"lang","in":"query","schema":{"type":"string","example":"en"}},{"name":"lat","in":"query","schema":{"type":"number","minimum":-90,"maximum":90},"description":"With lng: only activities within radius_km of this point (e.g. the traveller's location), each with distanceKm. With a city too, the city scope stays and the point narrows it. Invalid coordinates are ignored"},{"name":"lng","in":"query","schema":{"type":"number","minimum":-180,"maximum":180},"description":"With lat: see lat"},{"name":"radius_km","in":"query","schema":{"type":"number","default":10,"maximum":100},"description":"Search radius around lat/lng in km (default 10, capped at 100)"},{"name":"sort","in":"query","schema":{"type":"string","enum":["rank","caliber","rating","reviews","price_asc","price_desc","distance"]},"description":"`distance` (nearest first) only with lat/lng. With lat/lng the default `rank` puts nearer activities a little higher"},{"name":"limit","in":"query","schema":{"type":"integer"}},{"name":"page","in":"query","schema":{"type":"integer"}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Activities, total and facets. With lat/lng every activity has distanceKm (km from the point) and the response has near.radiusKm (near.capped = more than 1,000 within the radius: the best-ranked 1,000, or the nearest 1,000 with sort=distance, are listed)","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/search/product/{id}":{"get":{"tags":["Search"],"operationId":"getActivity","summary":"Full activity detail","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"lang","in":"query","schema":{"type":"string"}},{"name":"currency","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Activity detail including bookingUrl for partner-only activities","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"404":{"$ref":"#/components/responses/Error404"}}}},"/api/availability/schedule":{"post":{"tags":["Availability"],"operationId":"getAvailabilityCalendar","summary":"Open dates and prices over a date range","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["activityId","dateFrom","dateTo"],"properties":{"activityId":{"type":"integer"},"dateFrom":{"type":"string","format":"date"},"dateTo":{"type":"string","format":"date"},"currency":{"type":"string"},"lang":{"type":"string"},"pickupId":{"type":"string","description":"Musement pickup point, when required"}}}}}},"responses":{"200":{"description":"Per-date availability","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/availability/slots":{"post":{"tags":["Availability"],"operationId":"getAvailabilitySlots","summary":"Live time slots and prices for one date","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["activityId","date"],"properties":{"activityId":{"type":"integer"},"date":{"type":"string","format":"date"},"adults":{"type":"integer","minimum":1},"children":{"type":"integer","minimum":0},"infants":{"type":"integer","minimum":0},"seniors":{"type":"integer","minimum":0},"youth":{"type":"integer","minimum":0},"currency":{"type":"string"},"lang":{"type":"string"},"pickupId":{"type":"string"}}}}}},"responses":{"200":{"description":"Slots with prices in minor units","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"properties":{"slots":{"type":"array","items":{"type":"object","additionalProperties":true,"properties":{"startTime":{"type":"string"},"available":{"type":"boolean"},"pricePerPerson":{"type":"integer","description":"Minor units"},"totalPrice":{"type":"integer","description":"Minor units"},"specialTotalPrice":{"type":"integer","description":"Discounted total, when an offer is active"},"currency":{"type":"string"},"label":{"type":"string"},"variantId":{"type":"string"},"inventoryId":{"type":"string"},"remaining":{"type":["integer","null"]}}}},"bookable":{"type":"boolean"},"bookingUrl":{"type":"string","description":"Partner URL when not bookable in-house"}}}}}},"400":{"$ref":"#/components/responses/Error400"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/options":{"post":{"tags":["Booking"],"operationId":"getBookingOptions","summary":"Ticket types, age bands, cancellation policy and booking questions","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["activityId"],"properties":{"activityId":{"type":"integer"},"productOptionCode":{"type":"string"},"currency":{"type":"string"},"lang":{"type":"string"}}}}}},"responses":{"200":{"description":"Booking options","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/hold":{"post":{"tags":["Booking"],"operationId":"holdBooking","summary":"Hold inventory with the supplier and create a Stripe checkout","description":"Places a real supplier hold on this host. Redirect the traveller to the returned checkoutUrl; call /api/booking/confirm after payment (a Stripe webhook also confirms).","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["activityId","date","productOptionCode","contact","adults"],"properties":{"activityId":{"type":"integer"},"date":{"type":"string","format":"date"},"startTime":{"type":"string"},"productOptionCode":{"type":"string"},"inventoryId":{"type":"string"},"pickupId":{"type":"string"},"adults":{"type":"integer","minimum":1},"children":{"type":"integer","minimum":0},"infants":{"type":"integer","minimum":0},"seniors":{"type":"integer","minimum":0},"youth":{"type":"integer","minimum":0},"contact":{"type":"object","required":["firstName","lastName","email","phone"],"properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string","description":"E.164, e.g. +393331234567"}}},"travelers":{"type":"array","items":{"type":"object","additionalProperties":true}},"bookingAnswers":{"type":"object","additionalProperties":true},"languageGuide":{"type":"object","additionalProperties":true},"totalPrice":{"type":"integer","description":"Minor units the traveller agreed to"},"currency":{"type":"string","default":"EUR"},"lang":{"type":"string"},"baseUrl":{"type":"string","description":"Your origin for Stripe return URLs (authenticated callers only)"},"testBooking":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Hold created","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"properties":{"internalRef":{"type":"string","description":"Booking reference for status/cancel/amend"},"checkoutUrl":{"type":"string","description":"Stripe checkout; its session_id (returned on the success redirect) is what /confirm takes"},"expiresAt":{"type":"string","format":"date-time","description":"Supplier hold expiry"},"holdExpiresAt":{"type":["string","null"],"format":"date-time","description":"When the supplier hold lapses: Viator validUntil, Tiqets order +30 min, Headout uncaptured booking +1 h. null when the supplier gives none (Musement). Pay before it, or the hold is renewed or refused at confirm."},"retailPrice":{"type":"integer","description":"Charged to the traveller, minor units"},"grossPrice":{"type":"integer","description":"Supplier list price, minor units"},"currency":{"type":"string"},"testBooking":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/Error400"},"409":{"$ref":"#/components/responses/Error409"},"422":{"$ref":"#/components/responses/Error422"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/booking/confirm":{"post":{"tags":["Booking"],"operationId":"confirmBooking","summary":"Confirm a paid hold with the supplier","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sessionId"],"properties":{"sessionId":{"type":"string","description":"Stripe checkout session id (cs_...)"}}}}}},"responses":{"200":{"description":"Confirmed booking with voucher/ticket URLs","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"409":{"$ref":"#/components/responses/Error409"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/status":{"post":{"tags":["Booking"],"operationId":"getBookingStatus","summary":"Live booking status","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef"],"properties":{"internalRef":{"type":"string","description":"Booking reference returned by /api/booking/hold"}}}}}},"responses":{"200":{"description":"Status, voucherUrl, ticketUrl","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/cancel-quote":{"post":{"tags":["Booking"],"operationId":"getCancellationQuote","summary":"Whether a booking can be cancelled, and the refund","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef"],"properties":{"internalRef":{"type":"string","description":"Booking reference returned by /api/booking/hold"}}}}}},"responses":{"200":{"description":"Cancellation quote","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/cancel":{"post":{"tags":["Booking"],"operationId":"cancelBooking","summary":"Cancel a booking with the supplier and refund","description":"Only the API key (or account) that placed the booking may call this; anyone else gets 404, the same as an unknown reference. A booking that was never paid can be released by anyone (abandoned checkout).","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef"],"properties":{"internalRef":{"type":"string","description":"Booking reference returned by /api/booking/hold"}}}}}},"responses":{"200":{"description":"Cancellation result","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"409":{"$ref":"#/components/responses/Error409"},"422":{"$ref":"#/components/responses/Error422"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/booking/amend-quote":{"post":{"tags":["Booking"],"operationId":"getAmendmentQuote","summary":"Quote a change of date, time, ticket type or party","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef"],"properties":{"internalRef":{"type":"string","description":"Booking reference returned by /api/booking/hold"},"changes":{"type":"object","properties":{"travelDate":{"type":"string","format":"date"},"startTime":{"type":"string","pattern":"^\\d{2}:\\d{2}$"},"productOptionCode":{"type":"string"},"passengers":{"type":"object","properties":{"adults":{"type":"integer","minimum":1},"children":{"type":"integer","minimum":0},"infants":{"type":"integer","minimum":0},"seniors":{"type":"integer","minimum":0},"youth":{"type":"integer","minimum":0}},"required":["adults"]}}}}}}}},"responses":{"200":{"description":"Amendment quote with quoteRef and priceDifference","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"422":{"$ref":"#/components/responses/Error422"}}}},"/api/booking/amend":{"post":{"tags":["Booking"],"operationId":"amendBooking","summary":"Apply a quoted amendment","description":"Only the API key (or account) that placed the booking may call this; anyone else gets 404, the same as an unknown reference.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef","quoteRef"],"properties":{"internalRef":{"type":"string"},"quoteRef":{"type":"string"},"changes":{"type":"object","properties":{"travelDate":{"type":"string","format":"date"},"startTime":{"type":"string","pattern":"^\\d{2}:\\d{2}$"},"productOptionCode":{"type":"string"},"passengers":{"type":"object","properties":{"adults":{"type":"integer","minimum":1},"children":{"type":"integer","minimum":0},"infants":{"type":"integer","minimum":0},"seniors":{"type":"integer","minimum":0},"youth":{"type":"integer","minimum":0}},"required":["adults"]}}}}}}}},"responses":{"200":{"description":"Amendment result","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"409":{"$ref":"#/components/responses/Error409"},"422":{"$ref":"#/components/responses/Error422"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/booking/reschedule":{"post":{"tags":["Booking"],"operationId":"rescheduleBooking","summary":"Move a confirmed booking to a new date/time (supported suppliers)","description":"Only the API key (or account) that placed the booking may call this; anyone else gets 404, the same as an unknown reference.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["internalRef","date"],"properties":{"internalRef":{"type":"string"},"date":{"type":"string","format":"date"},"startTime":{"type":"string"},"inventoryId":{"type":"string"}}}}}},"responses":{"200":{"description":"Reschedule result","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/Error400"},"404":{"$ref":"#/components/responses/Error404"},"409":{"$ref":"#/components/responses/Error409"},"422":{"$ref":"#/components/responses/Error422"},"429":{"$ref":"#/components/responses/Error429"}}}},"/api/ai/chat.json":{"post":{"tags":["Agent"],"operationId":"agentChat","summary":"Hosted agent turn (non-streaming)","description":"Runs one turn of the hosted TourScanner agent over the same tools as the MCP server. Requires an API key. `/api/ai/chat` is the streaming (NDJSON) variant.","security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","items":{"type":"object","required":["role","content"],"properties":{"role":{"type":"string","enum":["user","assistant"]},"content":{"type":"string"}}}},"sessionId":{"type":"string"},"previousApiCalls":{"type":"array","items":{"type":"object","additionalProperties":true}},"location":{"type":"object","description":"Optional. Where the traveller is right now: the agent then answers \"near me\", \"here\", \"nearby\" around this point (nearest city + search_activities near) instead of asking where they are. Invalid values are ignored. Never stored or logged at more than ~1 km precision.","required":["lat","lng"],"properties":{"lat":{"type":"number","minimum":-90,"maximum":90},"lng":{"type":"number","minimum":-180,"maximum":180},"accuracyM":{"type":"number","minimum":0,"description":"Accuracy of the fix in metres, as the device reports it"}}}}}}}},"responses":{"200":{"description":"Agent reply","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"properties":{"sessionId":{"type":"string"},"message":{"type":"string"},"quickReplies":{"type":"array","items":{"type":"string"}},"apiCalls":{"type":"array","items":{"type":"object","additionalProperties":true}},"bookingUrl":{"type":"object","additionalProperties":true},"error":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Error401"},"429":{"$ref":"#/components/responses/Error429"}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}},"parameters":{"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique per logical request (e.g. a UUID). Retrying with the same key and body replays the first response for 24h; a different body returns 422 IDEMPOTENCY_KEY_REUSED.","schema":{"type":"string","maxLength":255}}},"schemas":{"Error":{"type":"object","description":"Error body. `message` is human-readable; branch on `data.code`.","properties":{"statusCode":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"code":{"type":"string","enum":["VALIDATION_ERROR","AUTH_REQUIRED","FORBIDDEN","NOT_FOUND","RATE_LIMITED","SLOT_UNAVAILABLE","PRICE_CHANGED","PAX_INVALID","NOT_CANCELLABLE","NOT_SUPPORTED","HOLD_EXPIRED","PAYMENT_REQUIRED","PROVIDER_ERROR","PROVIDER_TIMEOUT","IDEMPOTENCY_KEY_REUSED","IDEMPOTENCY_IN_PROGRESS","DEMO_SESSION","BOOKING_FAILED"]},"retryable":{"type":"boolean","description":"True when retrying the same request can succeed."}}}}}},"responses":{"Error400":{"description":"Invalid input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error401":{"description":"Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error409":{"description":"Conflict (idempotency in progress, or non-payable session)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error422":{"description":"Business error (see data.code)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error429":{"description":"Rate limited (see Retry-After)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}