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.
https://beta.tourscanner.aiJSONX-API-KeyThis 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:
/api/search/autocompleteLow-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
| Name | Type | Required | Description |
|---|---|---|---|
q | string | Yes | Search query (min 2 chars) |
lang | string | No | Language code for localized names (default: en) |
limit | number | No | Cap per bucket (default: 5, max: 10) |
providers | string | No | Comma-separated provider IDs (2=Tiqets, 3=Viator, 7=Headout). Scopes the activities bucket only — cities, pois, tags stay provider-agnostic. |
/api/search/destinationsSearch 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
| Name | Type | Required | Description |
|---|---|---|---|
q | string | Cond. | Search query (min 2 chars). Required unless id is given. |
id | number | Cond. | City id (e.g. 84632) to look up directly. Alias: cityId. Returns a single destination instead of results. |
lang | string | No | Language code (default: en) |
limit | number | No | Max results in search mode (default: 10, max: 20) |
/api/search/activitiesSearch activities by text, city, or filters. Returns paginated results with pricing.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
q | string | No | Full-text search query |
city | number|string | No | City ID or slug/name (e.g. 84632 or rome). Unknown value returns empty. |
city_name | string | No | Deprecated alias for city when passing a name |
parentCountryId | number | No | Country scope (wajanga country id). Echoed as resolvedParent. Not with city or parentRegionId. |
parentRegionId | number | No | Region scope (wajanga region id). Echoed as resolvedParent. Not with city or parentCountryId. |
sort | string | No | rank (default, recommended ranking), caliber, rating, reviews, price_asc, price_desc, distance (nearest first, only with lat/lng) |
lat, lng | number | No | A 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_km | number | No | Radius around lat/lng in km (default: 10, max: 100) |
page | number | No | Page number (default: 1) |
limit | number | No | Results per page (default: 20, max: 100) |
lang | string | No | Language code (default: en). Localizes name, description, url when a translation exists. |
currency | string | No | 3-letter currency code (default: EUR). Drives the price field. |
providers | string | No | Comma-separated provider IDs (2=Tiqets, 3=Viator, 7=Headout) |
min_rating | number | No | Minimum review rating |
min_price | number | No | Minimum price in the selected currency's minor unit (cents for EUR/USD) |
max_price | number | No | Maximum price in the selected currency's minor unit |
tags | string | No | Comma-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. |
category | number|string | No | Category 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. |
pois | string | No | Comma-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. |
features | string | No | Comma-separated characteristic keys — AND semantics, e.g. free_cancellation,mobile_ticket. Supported: free_cancellation, instant_confirmation, mobile_ticket, accessible. Unknown tokens are dropped. |
languages | string | No | Comma-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_pois | string | No | Comma-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_tags | string | No | Comma-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_types | string | No | Comma-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. |
facets | boolean | No | Return 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
}
}/api/search/poisPoints 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
| Name | Type | Required | Description |
|---|---|---|---|
city | number|string | Yes | City ID or slug (e.g. 87000 or florence). Alias: cityId. Unknown value returns empty. |
type | string | No | attraction, district, or landmark (default: all) |
category | string | No | Category id or slug, CSV for multiple (e.g. museums,theme-parks). Each POI also returns its category object. |
q | string | No | Name or slug substring filter |
limit | number | No | POIs to return (default: 20, max: 100) |
sort | string | No | activities (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) |
withActivities | boolean | No | When true, embeds top activities per POI (sorted by caliber) |
activitiesLimit | number | No | Per-POI activity cap when withActivities=true (default: 5, max: 20) |
lang | string | No | Language code for localized POI names (default: en) |
/api/search/product/:idGet 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
| Name | Type | Required | Description |
|---|---|---|---|
id | number | Yes | Activity ID (URL param) |
lang | string | No | Language code (default: en) |
/api/search/product/:id/reviewsPaginated reviews for a single activity.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | number | Yes | Activity ID (URL param) |
page | number | No | Page number (default: 1) |
limit | number | No | Reviews per page (default: 10, max: 50) |
sort | string | No | recent (default), rating_desc, rating_asc |
minRating | number | No | Minimum star rating |
lang | string | No | Restrict to reviews written in this language |
/api/availability/scheduleGet a monthly availability calendar. Returns which dates have available slots.
Body Parameters
| Name | Type | Required | Description |
|---|---|---|---|
activityId | number | Yes | Activity ID |
dateFrom | string | Yes | Start date (YYYY-MM-DD) |
dateTo | string | Yes | End date (YYYY-MM-DD) |
currency | string | No | Currency code (default: EUR) |
/api/availability/slotsGet real-time bookable time slots for a specific date. Use after the user selects a date from the schedule.
Body Parameters
| Name | Type | Required | Description |
|---|---|---|---|
activityId | number | Yes | Activity ID |
date | string | Yes | Date (YYYY-MM-DD) |
adults | number | No | Number of adults (default: 2) |
children | number | No | Number of children (default: 0) |
currency | string | No | Currency code (default: EUR) |
/api/booking/optionsGet booking form fields: product variants, age bands, booking questions, language guides, and cancellation policy.
Body Parameters
| Name | Type | Required | Description |
|---|---|---|---|
activityId | number | Yes | Activity ID |
currency | string | No | Currency code (default: EUR) |
productOptionCode | string | No | Specific variant code (returns all if omitted) |
/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
| Name | Type | Required | Description |
|---|---|---|---|
activityId | number | Yes | Activity ID |
date | string | Yes | Travel date (YYYY-MM-DD) |
startTime | string | No | Time slot (HH:MM) or omit for ON_DEMAND |
productOptionCode | string | Yes | Product variant code (from booking/options) |
passengers | object|array | Yes | Either { adults, children?, infants?, seniors?, youth? } or [{ bandId: 'adult'|'child'|..., count }]. adults >= 1 is required. |
contact | object | Yes | { firstName, lastName, email, phone } |
travelers | array | No | Traveler details per person |
bookingAnswers | array | No | Answers to booking questions |
languageGuide | object | No | Selected language guide |
currency | string | No | Currency (default: EUR) |
baseUrl | string | No | Origin 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"
}/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
| Name | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | Stripe 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"
}/api/booking/statusCheck the current status of a booking.
Body Parameters
| Name | Type | Required | Description |
|---|---|---|---|
internalRef | string | Yes | Internal booking reference (e.g. TS-3-abc123-def456). ref is accepted as an alias. |
/api/booking/cancelCancel a booking. Cancels with the provider and refunds the Stripe payment.
Body Parameters
| Name | Type | Required | Description |
|---|---|---|---|
internalRef | string | Yes | Internal booking reference. ref is accepted as an alias. |
/api/data/destinationsDiscover 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
| Name | Type | Required | Description |
|---|---|---|---|
q | string | No | Filter by city name/slug substring |
limit | number | No | Max scopes to return (default: 50, max: 500) |
cursor | number | No | Opaque pagination cursor returned as nextCursor |
/api/data/categoriesList 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
| Name | Type | Required | Description |
|---|---|---|---|
lang | string | No | Language code for localized names (default: en) |
limit | number | No | Max categories (default: 200, max: 500) |
{
"categories": [
{ "id": 15, "slug": "sightseeing", "name": "Sightseeing" },
{ "id": 5, "slug": "museums", "name": "Museums" }, ...
]
}Stripe Payment Flow
The booking system uses Stripe's manual capture mode for safe payment handling:
- Hold —
POST /api/booking/holdcreates a Stripe Checkout session withcapture_method: manual. The customer's card is authorized but not charged. - Redirect — Redirect the customer to the returned
checkoutUrl. Stripe handles card input securely. - Success callback — After payment, Stripe redirects to your success URL with
?session_id=.... - Confirm — Call
POST /api/booking/confirmwith the session ID. This confirms with the provider, then captures the payment. - Failure — If the provider rejects the booking, the payment authorization is cancelled automatically (no charge).
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.
https://beta.tourscanner.ai/api/mcpStreamable HTTPAPI 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://beta.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://beta.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://beta.tourscanner.ai/api/mcp?api_key=YOUR_API_KEY
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://beta.tourscanner.ai/api/mcp with the Authorization: Bearer header.
Available Tools
| Tool | Description |
|---|---|
search_destinations | Cities and attractions by name |
search_activities | Activities 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_activity | Full detail, catalog prices, metadata, bookability |
check_availability | Live slots and prices for a date and party; next open dates when closed |
get_availability_calendar | Open dates and cheapest price across a range |
get_booking_options | Ticket types, age bands, cancellation policy, booking questions |
create_checkout_session | Check a chosen slot live and return the tourscanner.io checkout link, where the traveller books and pays |
get_booking_status | Live status, voucher and ticket links |
get_cancellation_quote | Cancellability and refund |
cancel_booking | Cancel a booking (demo) |
get_amendment_quote | Quote a date, time, ticket or party change |
amend_booking | Apply 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.
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."
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
| Code | Meaning |
|---|---|
200 | Success |
400 | Bad request — missing or invalid parameters |
404 | Resource not found |
401 | API key required |
409 | Conflict — idempotent request still in progress, or a non-payable session |
422 | Unprocessable — provider error or business logic failure |
429 | Rate limited — see Retry-After |
500 | Internal 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.