Live API — booking endpoints place real supplier holds and real Stripe checkouts. Agent (MCP) checkouts return a tourscanner.io checkout link; agent cancel and amend run in demo mode.

API Documentation

Full REST API for searching, checking availability, and booking tours and activities. All responses are JSON. Every request needs an API key, sent as X-API-Key. Keys are issued by TourScanner on request: write to [email protected]. Without a key the API answers 401 with {"error": "API_KEY_REQUIRED"}. The "Try it" panels on this page run from your browser without one.

Base URL
https://tourscanner.ai
Format
JSON
Auth
X-API-Key
Sandbox vs Production
This host is production: holds and payments are real. For a sandbox environment on provider test rails, contact us.
Machine-readable spec: OpenAPI 3.1.

Booking Workflow

The typical integration follows this flow:

1
Search
Find cities & activities
2
Availability
Check dates & time slots
3
Hold & Pay
Reserve + Stripe checkout
4
Confirm
Finalize booking
GET/api/search/autocomplete

Low-latency typeahead. Returns a grouped payload — cities, pois, tags, activities — suitable for rendering a search-as-you-type dropdown. Designed to be called on every keystroke.

Parameters

NameTypeRequiredDescription
qstringYesSearch query (min 2 chars)
langstringNoLanguage code for localized names (default: en)
limitnumberNoCap per bucket (default: 5, max: 10)
providersstringNoComma-separated provider IDs (2=Tiqets, 3=Viator, 7=Headout). Scopes the activities bucket only — cities, pois, tags stay provider-agnostic.
LiveTry it
GET/api/search/destinations

Search for cities and destinations by name (q), or look up a single city by id to hydrate a destination header. The lookup mode returns a destination object — { id, name, nameLocalized, slug, country, countryCode, region, population, lat, lng } — with the name localized to lang and the full localized country name; it is null when the id isn't a city.

Parameters

NameTypeRequiredDescription
qstringCond.Search query (min 2 chars). Required unless id is given.
idnumberCond.City id (e.g. 84632) to look up directly. Alias: cityId. Returns a single destination instead of results.
langstringNoLanguage code (default: en)
limitnumberNoMax results in search mode (default: 10, max: 20)
LiveTry it
GET/api/search/activities

Search activities by text, city, or filters. Returns paginated results with pricing.

Parameters

NameTypeRequiredDescription
qstringNoFull-text search query
citynumber|stringNoCity ID or slug/name (e.g. 84632 or rome). Unknown value returns empty.
city_namestringNoDeprecated alias for city when passing a name
parentCountryIdnumberNoCountry scope (wajanga country id). Echoed as resolvedParent. Not with city or parentRegionId.
parentRegionIdnumberNoRegion scope (wajanga region id). Echoed as resolvedParent. Not with city or parentCountryId.
sortstringNorank (default, recommended ranking), caliber, rating, reviews, price_asc, price_desc, distance (nearest first, only with lat/lng)
lat, lngnumberNoA point, e.g. the traveller's current location (lat -90..90, lng -180..180). Only activities within radius_km of it are returned, each with distanceKm, and the response carries near.radiusKm (plus near.capped: true when more than 1,000 activities are within it: the list then covers the best-ranked 1,000, or the nearest 1,000 with sort=distance). With city too, the city scope stays and the point narrows it; without, the search runs around the point wherever it is. The default rank sort puts nearer activities a little higher. Invalid coordinates are ignored.
radius_kmnumberNoRadius around lat/lng in km (default: 10, max: 100)
pagenumberNoPage number (default: 1)
limitnumberNoResults per page (default: 20, max: 100)
langstringNoLanguage code (default: en). Localizes name, description, url when a translation exists.
currencystringNo3-letter currency code (default: EUR). Drives the price field.
providersstringNoComma-separated provider IDs (2=Tiqets, 3=Viator, 7=Headout)
min_ratingnumberNoMinimum review rating
min_pricenumberNoMinimum price in the selected currency's minor unit (cents for EUR/USD)
max_pricenumberNoMaximum price in the selected currency's minor unit
tagsstringNoComma-separated tag IDs or slugs — OR semantics (any match), e.g. 70787,food-tours. Legacy singular tag accepts one value. Unknown tokens are dropped; if none resolve the response is empty.
categorynumber|stringNoCategory ID or slug (e.g. 15 or sightseeing). Returns activities carrying any tag in that category (expanded via tag_categories), scoped to the same city/provider. Combine with city. Unknown/empty category returns empty. To browse one tag at a time use tags instead.
poisstringNoComma-separated POI IDs or slugs — OR semantics, e.g. colosseum,vatican-museums. Legacy singular poi accepts one value. Unknown tokens are dropped; if none resolve the response is empty.
featuresstringNoComma-separated characteristic keys — AND semantics, e.g. free_cancellation,mobile_ticket. Supported: free_cancellation, instant_confirmation, mobile_ticket, accessible. Unknown tokens are dropped.
languagesstringNoComma-separated guide/audio language codes — OR semantics, e.g. en,it. Matched against the activity's languages array (ISO 639-1, plus a few 639-3 like cmn). Feed a facets.languages chip's code straight back.
also_poisstringNoComma-separated POI ids (or slugs), ANDed with the page's own poi scope: "of these Colosseum tours, the ones that also cover the Roman Forum". OR within the dimension. Feed a facets.landmarks chip's id straight back.
also_tagsstringNoComma-separated tag ids (or slugs), ANDed with the page's own tags / pois scope. OR within the dimension. Feed a facets.tags chip's id straight back.
tour_typesstringNoComma-separated tour-type keys — AND semantics, e.g. guided,private. Supported: guided, private, ticket, audioguide, night. Combines with features (also AND). Unknown tokens are dropped.
facetsbooleanNoReturn a facets object alongside the results (default: true). Pass false to skip and shave latency.

Facets

When facets=true (default), the response includes a facets object with refinement options. Each facet's counts are computed over the activities matching every other dimensional filter — so clicking a provider chip re-narrows the tag/price/rating/feature counts, but the provider counts themselves stay stable so the user can pivot. Bounds in facets.prices are in the currency's minor unit (cents for EUR/USD), matching the min_price / max_price params. Capped at the top 3,000 activities by caliber for a city/POI-scoped query (1,000 for a broad browse) when the matching corpus is larger — facets.capped tells you when that happened. The facets.features (characteristics) and facets.tourTypes arrays correspond to the features and tour_types filter params — feed a chip's key straight back to filter. facets.languages ({ code, count }) drives the languages filter, and facets.landmarks ({ id, name, slug, poiType, count }) lists the other POIs the matching tours also cover — the "additional landmarks" filter, fed back through also_pois.

{
  "facets": {
    "tags":      [{ "id": 70787, "name": "Walking Tours", "slug": "walking-tours", "type": "activity_type", "count": 487 }, ...],
    "providers": [{ "id": 3, "count": 320 }, { "id": 4, "count": 88 }, ...],
    "prices":    { "currency": "EUR", "min": 500, "max": 98000, "buckets": [{ "from": 0, "to": 2000, "count": 4 }, { "from": 2000, "to": 4000, "count": 12 }, ..., { "from": 50000, "to": null, "count": 7 }] },
    "ratings":   [{ "min": 3, "count": 1024 }, { "min": 3.5, "count": 870 }, { "min": 4, "count": 520 }, { "min": 4.5, "count": 230 }],
    "features":  [{ "key": "free_cancellation", "count": 234 }, { "key": "instant_confirmation", "count": 412 }, { "key": "mobile_ticket", "count": 540 }, { "key": "accessible", "count": 130 }],
    "tourTypes": [{ "key": "guided", "count": 410 }, { "key": "private", "count": 96 }, { "key": "ticket", "count": 220 }, { "key": "audioguide", "count": 24 }, { "key": "night", "count": 31 }],
    "languages": [{ "code": "en", "count": 612 }, { "code": "it", "count": 288 }, { "code": "es", "count": 141 }, ...],
    "landmarks": [{ "id": 4412, "name": "Roman Forum", "slug": "roman-forum", "poiType": "monument", "count": 203 }, ...],
    "corpusSize": 1234,
    "capped": false
  }
}
LiveTry it
GET/api/search/tags

Search tags (activity categories and filters) by name, or look up a single tag by id to hydrate a tag detail / landing header. Optionally include the top-ranked activities for each tag, so you can build faceted browse UIs without hardcoding IDs. Filter to a category's tags with category, scope/rank to a city with cityId, and set withActivities=true — together these return a whole category landing page (ranked tags, each with its top activities) in a single call.

Parameters

NameTypeRequiredDescription
idnumberNoLook up a single tag by its numeric id (e.g. 70781). Alias: tagId. Bypasses the visibility filter, so hidden tags are still retrievable; combine with withActivities for a tag detail view.
qstringNoName or slug substring
typestringNoactivity_type or filter (default: both)
limitnumberNoTags to return (default: 20, max: 100)
withActivitiesbooleanNoWhen true, also returns top activities per tag
activitiesLimitnumberNoPer-tag activity cap when withActivities=true (default: 5, max: 20)
cityIdnumber|stringNoScope to a city, by ID or slug (e.g. 84632 or rome). Tags are then restricted to those with activities in the city and pre-sorted by city activity count desc (each carries activityCount); embedded activities are city-scoped. Alias: city. Pass the ID when the slug is ambiguous (e.g. prague exists in CZ and US). Unknown value returns empty.
categorynumber|stringNoRestrict to a category's tags (ts.categories ID or slug, e.g. 15 or sightseeing), via tag_categories. Combine with cityId + withActivities for a whole category landing page in one call.
langstringNoLanguage code for localized tag names (default: en)
LiveTry it
GET/api/search/pois

Points of interest in a city — attractions, districts, landmarks. POIs are pulled from ts.pois by their own city_id, so neighbour-city POIs incorrectly linked via legacy parser data cannot leak in. Sorted by how many of the city's valid/enabled/available activities link to each POI (POIs with no activities still appear, sorted last). Pass sort to opt into the curated ranking order or popularity (activity count × review volume); omit it and the order is unchanged.

Scoping note: counts (and embedded activities, if requested) are restricted to activities whose city_id equals the queried city. Day trips that visit a Florence POI but depart from Rome are excluded, matching what a user sees when browsing the city.

Parameters

NameTypeRequiredDescription
citynumber|stringYesCity ID or slug (e.g. 87000 or florence). Alias: cityId. Unknown value returns empty.
typestringNoattraction, district, or landmark (default: all)
categorystringNoCategory id or slug, CSV for multiple (e.g. museums,theme-parks). Each POI also returns its category object.
qstringNoName or slug substring filter
limitnumberNoPOIs to return (default: 20, max: 100)
sortstringNoactivities (default — link count, the pre-existing order), ranking (curated rank ascending — unranked POIs are excluded, so a city with none returns empty), or popularity (activityCount × reviewCount)
withActivitiesbooleanNoWhen true, embeds top activities per POI (sorted by caliber)
activitiesLimitnumberNoPer-POI activity cap when withActivities=true (default: 5, max: 20)
langstringNoLanguage code for localized POI names (default: en)
LiveTry it
GET/api/search/product/:id

Get full product detail including description, gallery, itinerary, pricing, features, and cancellation policy. reviewsSummary carries a generated digest of the activity's reviews for the requested lang — null when that language has no digest (there is no English fallback), which is the common case since coverage is partial. Use its generatedAt to judge whether the digest still reflects the current reviewsCount.

Parameters

NameTypeRequiredDescription
idnumberYesActivity ID (URL param)
langstringNoLanguage code (default: en)
LiveTry it
GET/api/search/product/:id/reviews

Paginated reviews for a single activity.

Parameters

NameTypeRequiredDescription
idnumberYesActivity ID (URL param)
pagenumberNoPage number (default: 1)
limitnumberNoReviews per page (default: 10, max: 50)
sortstringNorecent (default), rating_desc, rating_asc
minRatingnumberNoMinimum star rating
langstringNoRestrict to reviews written in this language
LiveTry it
POST/api/availability/schedule

Get a monthly availability calendar. Returns which dates have available slots.

Body Parameters

NameTypeRequiredDescription
activityIdnumberYesActivity ID
dateFromstringYesStart date (YYYY-MM-DD)
dateTostringYesEnd date (YYYY-MM-DD)
currencystringNoCurrency code (default: EUR)
LiveTry it
POST/api/availability/slots

Get real-time bookable time slots for a specific date. Use after the user selects a date from the schedule.

Body Parameters

NameTypeRequiredDescription
activityIdnumberYesActivity ID
datestringYesDate (YYYY-MM-DD)
adultsnumberNoNumber of adults (default: 2)
childrennumberNoNumber of children (default: 0)
currencystringNoCurrency code (default: EUR)
LiveTry it
POST/api/booking/options

Get booking form fields: product variants, age bands, booking questions, language guides, and cancellation policy.

Body Parameters

NameTypeRequiredDescription
activityIdnumberYesActivity ID
currencystringNoCurrency code (default: EUR)
productOptionCodestringNoSpecific variant code (returns all if omitted)
LiveTry it
POST/api/booking/hold

Hold a booking and create a Stripe Checkout session. Returns a checkoutUrl to redirect the customer to Stripe for payment. The hold uses manual capture — the card is authorized but not charged until the booking is confirmed with the provider.

Body Parameters

NameTypeRequiredDescription
activityIdnumberYesActivity ID
datestringYesTravel date (YYYY-MM-DD)
startTimestringNoTime slot (HH:MM) or omit for ON_DEMAND
productOptionCodestringYesProduct variant code (from booking/options)
passengersobject|arrayYesEither { adults, children?, infants?, seniors?, youth? } or [{ bandId: 'adult'|'child'|..., count }]. adults >= 1 is required.
contactobjectYes{ firstName, lastName, email, phone }
travelersarrayNoTraveler details per person
bookingAnswersarrayNoAnswers to booking questions
languageGuideobjectNoSelected language guide
currencystringNoCurrency (default: EUR)
baseUrlstringNoOrigin to send the customer back to after Stripe (e.g. https://yourdomain.com). Authenticated/API callers only — the customer lands on {baseUrl}/booking/success or /booking/cancel. Defaults to the request's own origin.

Response

{
  "checkoutUrl": "https://checkout.stripe.com/...",
  "internalRef": "TS-3-abc123-def456",
  "expiresAt": "2026-04-10T15:30:00Z",
  "retailPrice": 5400,
  "currency": "EUR"
}
LiveTry it
POST/api/booking/confirm

Confirm a booking after Stripe payment. Call this with the session_id from the Stripe success redirect. This captures the payment and confirms with the provider.

Body Parameters

NameTypeRequiredDescription
sessionIdstringYesStripe Checkout session ID (from success URL query param)

Response

{
  "status": "confirmed",
  "internalRef": "TS-3-abc123-def456",
  "providerRef": "BR-12345678",
  "voucherUrl": "https://...",
  "ticketUrl": "https://...",
  "activityName": "Colosseum Skip-the-Line Tour",
  "travelDate": "2026-05-15"
}
LiveTry it
POST/api/booking/status

Check the current status of a booking.

Body Parameters

NameTypeRequiredDescription
internalRefstringYesInternal booking reference (e.g. TS-3-abc123-def456). ref is accepted as an alias.
LiveTry it
POST/api/booking/cancel

Cancel a booking. Cancels with the provider and refunds the Stripe payment.

Body Parameters

NameTypeRequiredDescription
internalRefstringYesInternal booking reference. ref is accepted as an alias.
LiveTry it
GET/api/data/destinations

Discover the ranking scopes available on the platform, resolved into human-friendly { city, poi } records. Use this to drive a "popular destinations" UI without hardcoding city IDs.

Parameters

NameTypeRequiredDescription
qstringNoFilter by city name/slug substring
limitnumberNoMax scopes to return (default: 50, max: 500)
cursornumberNoOpaque pagination cursor returned as nextCursor
LiveTry it
GET/api/data/categories

List the categories (ts.categories) — the high-level groups (Sightseeing, Museums, Day Trips…) that bundle tags via ts.tag_categories. Drill into a category's tags with /api/search/tags?category=… (add cityId to rank by city popularity). For the flat list of browseable tags use /api/data/tags; for free-text tag search use /api/search/tags.

Parameters

NameTypeRequiredDescription
langstringNoLanguage code for localized names (default: en)
limitnumberNoMax categories (default: 200, max: 500)
{
  "categories": [
    { "id": 15, "slug": "sightseeing", "name": "Sightseeing" },
    { "id": 5, "slug": "museums", "name": "Museums" }, ...
  ]
}
LiveTry it
GET/api/data/tags

List the globally-visible tags (Walking Tours, Food Tours, Skip-the-line…) for a browseable tag tree. This is what /api/data/categories returned previously. For free-text tag search use /api/search/tags; for the tags of one category use /api/search/tags?category=….

Parameters

NameTypeRequiredDescription
typestringNoactivity_type or filter (default: both)
langstringNoLanguage code for localized names (default: en)
limitnumberNoMax tags (default: 200, max: 500)
LiveTry it

Stripe Payment Flow

The booking system uses Stripe's manual capture mode for safe payment handling:

  1. Hold — POST /api/booking/hold creates a Stripe Checkout session with capture_method: manual. The customer's card is authorized but not charged.
  2. Redirect — Redirect the customer to the returned checkoutUrl. Stripe handles card input securely.
  3. Success callback — After payment, Stripe redirects to your success URL with ?session_id=....
  4. Confirm — Call POST /api/booking/confirm with the session ID. This confirms with the provider, then captures the payment.
  5. Failure — If the provider rejects the booking, the payment authorization is cancelled automatically (no charge).
30-minute expiry
Stripe sessions expire after 30 minutes. If the customer doesn't complete payment in time, the hold is released.

MCP Server

The same capabilities are available as a remote Model Context Protocol (MCP) server, so AI agents (Claude, ChatGPT, Cursor, custom agents) can search, check live availability, prepare checkouts and manage bookings through tool calls.

Endpoint
https://tourscanner.ai/api/mcp
Transport
Streamable HTTP
Auth
API key (required)

Every request needs an API key, including initialize and tools/list. Keys are issued by TourScanner on request: [email protected]. Send it as Authorization: Bearer <key> or X-API-Key: <key>; clients that cannot send headers put it in the URL as ?api_key=<key>. Without a valid key the server answers 401 with WWW-Authenticate: Bearer. The examples below use YOUR_API_KEY: replace it with your own key.

Claude Code

Add the server with the key in a header:

claude mcp add --transport http tourscanner https://tourscanner.ai/api/mcp \
  --header "Authorization: Bearer YOUR_API_KEY"

Claude Desktop

In claude_desktop_config.json, bridge the remote server with mcp-remote and pass the key as a header:

{
  "mcpServers": {
    "tourscanner": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote", "https://tourscanner.ai/api/mcp",
        "--header", "Authorization: Bearer ${TOURSCANNER_API_KEY}"
      ],
      "env": { "TOURSCANNER_API_KEY": "YOUR_API_KEY" }
    }
  }
}

claude.ai custom connectors

Custom connectors cannot send headers, so the key goes in the server URL. In Settings → Connectors → Add custom connector, use:

https://tourscanner.ai/api/mcp?api_key=YOUR_API_KEY
A URL with a key is a secret
Anyone with that URL uses your key. Don't paste it in shared documents or tickets. If it leaks, revoke the key under Account → API Keys and ask us for a new one.

Other clients

Any client with remote MCP support (Streamable HTTP) can point at https://tourscanner.ai/api/mcp with the Authorization: Bearer header.

Available Tools

ToolDescription
search_destinationsCities and attractions by name
search_activitiesActivities by keywords, city (id or name), attraction, tag, rating, bookable-only, or around a point (near: { lat, lng, radiusKm }, e.g. the traveller's location; results carry distanceKm)
get_activityFull detail, catalog prices, metadata, bookability
check_availabilityLive slots and prices for a date and party; next open dates when closed
get_availability_calendarOpen dates and cheapest price across a range
get_booking_optionsTicket types, age bands, cancellation policy, booking questions
create_checkout_sessionCheck a chosen slot live and return the tourscanner.io checkout link, where the traveller books and pays
get_booking_statusLive status, voucher and ticket links
get_cancellation_quoteCancellability and refund
cancel_bookingCancel a booking (demo)
get_amendment_quoteQuote a date, time, ticket or party change
amend_bookingApply a quoted amendment (demo)

Tools carry MCP annotations (readOnlyHint, destructiveHint) so agent hosts can ask the user before a write. Booking tools only see bookings made with the calling key.

Try it with your location
The chat demo on the homepage can send an optional location: { lat, lng, accuracyM? } with each message, after the browser grants it — the same shape search_activities's near takes. The server resolves it to the nearest city and rounds it to ~1 km before it's ever logged.

Example Agent Prompt

"Find skip-the-line Colosseum tickets for 2 adults next Saturday.
Check live availability and prepare the cheapest morning slot."
Checkout links are real; cancel and amend run in demo mode
create_checkout_session checks the slot live and returns a tourscanner.io checkoutUrl with the slot, party and price pre-filled: nothing is booked until the traveller pays there. cancel_booking and amend_booking return real quotes but cancel and change nothing. Bookings through your own checkout go through /api/booking/hold → /api/booking/confirm.

Response Codes

CodeMeaning
200Success
400Bad request — missing or invalid parameters
404Resource not found
401API key required
409Conflict — idempotent request still in progress, or a non-payable session
422Unprocessable — provider error or business logic failure
429Rate limited — see Retry-After
500Internal server error

Error body

Errors keep a human message and add a stable data.code plus data.retryable:

{
  "statusCode": 422,
  "message": "This time slot is no longer available. Please select a different time.",
  "data": { "code": "SLOT_UNAVAILABLE", "retryable": false }
}

Codes: VALIDATION_ERROR, AUTH_REQUIRED, NOT_FOUND, RATE_LIMITED, SLOT_UNAVAILABLE, PRICE_CHANGED, PAX_INVALID, NOT_CANCELLABLE, NOT_SUPPORTED, HOLD_EXPIRED, PAYMENT_REQUIRED, PROVIDER_TIMEOUT, PROVIDER_ERROR, IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_IN_PROGRESS, DEMO_SESSION, BOOKING_FAILED.

Idempotent retries

/booking/hold, /confirm, /cancel, /amend and /reschedule accept an Idempotency-Key header (e.g. a UUID). Retrying with the same key and body within 24h replays the first response (Idempotent-Replayed: true) instead of booking twice; the same key with a different body returns IDEMPOTENCY_KEY_REUSED.