Skip to content

Tours API Reference

Base path: /api/v1/toursAuthentication: Most endpoints require app-user authentication. Protected routes accept Authorization: Bearer <jwt> or a valid Better Auth session cookie. GET /api/v1/tours/dashboard also allows unauthenticated callers and returns an empty featured_tours array for them. Feature epic: KUB-181 Tours and Itineraries See also: Reviews API

Status

Implemented for the current V1 route surface. A few enrichment behaviors remain planned, but the live request and response shapes below match the shipped handlers.


Tour Types

bar | nature | historical | music | culture | food | scenic | exercise

Tour Status Lifecycle

draft → active → completed → archived

Endpoints

Generate a Tour

http
POST /api/v1/tours
POST /api/v1/tours/generate

Auth: Required Rate limit: 5 requests per user per hour

POST /api/v1/tours is the canonical create route. POST /api/v1/tours/generate is supported as a compatibility alias.

Request body:

json
{
	"tour_type": "food",
	"duration_minutes": 90,
	"start_location": {
		"lat": 15.301,
		"lng": -61.388
	}
}

Both snake_case and camelCase request fields are accepted.

FieldTypeRequiredNotes
tour_typestring (enum)YesOne of the tour type values above
duration_minutesintegerYesMust be between 30 and 480
start_locationobjectNo{ lat, lng }

Response 202:

json
{
	"data": {
		"tourId": "clx...",
		"stopCount": 4,
		"status": "draft"
	}
}

Fetch GET /api/v1/tours/:tourId after creation for the persisted tour object and stop list.

Error responses:

StatusCodeDescription
422validation_errorInvalid tour_type, invalid duration, or malformed JSON
429rate_limitedMore than 5 tour generations in the past hour
503insufficient_stopsNot enough published content matches the tour type and location

List Tours

http
GET /api/v1/tours

Auth: Required Rate limit: 60 requests per authenticated user per minute Returns: Cursor-paginated list of the authenticated user's tours, ordered by created_at descending.

Query parameters:

ParameterTypeDefaultNotes
limitinteger20Max 50
cursorstringOpaque next cursor
statusstringOptional status filter

Response 200:

json
{
	"data": [
		{
			"id": "clx...",
			"tour_type": "food",
			"status": "completed",
			"duration_minutes": 90,
			"created_at": "2025-07-01T12:00:00Z",
			"stops": []
		}
	],
	"meta": {
		"cursor": null
	}
}

Each list item includes only a preview slice of stops: the first three ordered stops for that tour.


Get Tour Detail

http
GET /api/v1/tours/:tourId

Auth: Required (owner only)

Response 200: Full persisted tour object with ordered stops and per-stop media.

Error responses:

StatusCodeDescription
404not_foundTour not found

Get Tour Preview

http
GET /api/v1/tours/:tourId/preview

Auth: Required (owner only) Description: Returns the preview payload for display before starting the tour. For place stops, and for event stops that link to a place, the API attempts a live busy-rating lookup and falls back to the persisted stop value when a linked place location cannot be resolved or the enrichment result is unavailable.

Response 200:

json
{
	"data": {
		"id": "clx...",
		"tour_type": "food",
		"status": "draft",
		"duration_minutes": 90,
		"stops": [
			{
				"id": "clx...",
				"stop_order": 1,
				"title": "Roseau Market",
				"stop_type": "place",
				"entity_id": "42",
				"hero_image": "https://...",
				"busy_rating": "moderate"
			}
		]
	}
}

Review aggregates, Google badges, and other enrichment fields remain planned and are not yet part of the live preview response.


Get Stop Directions

http
GET /api/v1/tours/:tourId/stops/:stopId/directions

Auth: Required (owner only) Description: Returns cached directions from the previous stop to this stop when already stored on the tour stop. If directions have not been persisted yet, the route returns null.

Response 200:

json
{
	"data": null
}

When directions exist, the route returns the persisted directions_from_prev JSON payload as-is.


Complete a Tour

http
POST /api/v1/tours/:tourId/complete

Auth: Required (owner only) Description: Marks the tour as completed.

Request body: Empty

Response 200:

json
{
	"data": {
		"tourId": "clx...",
		"status": "completed"
	}
}

Submitting the request again for an already completed tour returns the same 200 response.


Queue Video Generation

http
POST /api/v1/tours/:tourId/video/generate

Auth: Required (owner only) Description: Returns a queued placeholder state for a completed tour. Actual background enqueue behavior remains follow-up work.

Response 202:

json
{
	"data": {
		"tourId": "clx...",
		"videoStatus": "queued"
	}
}

If the tour has not been completed yet, the route returns 422 validation_error.


Get Video Status

http
GET /api/v1/tours/:tourId/video/status

Auth: Required (owner only)

Response 200:

json
{
	"data": {
		"tourId": "clx...",
		"videoStatus": "done",
		"videoUrl": "https://r2.cloudflarestorage.com/..."
	}
}

Current videoStatus values are done and not_started.


Tour stop reviews and review moderation actions are documented in Reviews API.

  • POST /api/v1/tours/:tourId/stops/:stopId/reviews
  • GET /api/v1/tours/:tourId/stops/:stopId/reviews
  • POST /api/v1/reviews/:reviewId/votes
  • POST /api/v1/reviews/:reviewId/flag

Legacy compatibility aliases also remain supported for older clients:

  • POST /api/v1/tours/reviews/:reviewId/votes
  • POST /api/v1/tours/reviews/:reviewId/flag

Common Error Responses

All errors follow the standard API error envelope:

json
{
	"error": {
		"code": "not_found",
		"message": "Not found",
		"status": 404
	}
}
StatusMeaning
401Unauthorized — missing or invalid bearer token or session cookie
404Not found
422Unprocessable entity — validation failure
429Rate limit exceeded
503Service unavailable — e.g. no matching stops found

Tours Dashboard

http
GET /api/v1/tours/dashboard

Returns event type categories, the caller's recent completed tours, and a static action descriptor for generating a new tour. Designed as the entry point for the tour discovery surface.

Authentication

Optional. Unauthenticated callers receive an empty featured_tours array.

Rate Limits

60 requests per minute. Authenticated callers are bucketed by user ID; unauthenticated callers are bucketed by the resolved client key.

Response 200 OK

json
{
	"data": {
		"categories": [
			{ "id": "1", "name": "Festivals", "slug": "festivals", "event_count": 12 }
		],
		"featured_tours": [
			{
				"id": "tour_001",
				"tour_type": "culture",
				"status": "completed",
				"duration_minutes": 120,
				"total_distance_meters": 3500,
				"stop_count": 4,
				"generated_at": "2025-01-01T10:00:00.000Z",
				"created_at": "2025-01-01T09:00:00.000Z"
			}
		],
		"generate_tour": {
			"endpoint": "/api/v1/tours",
			"method": "POST",
			"auth_required": true,
			"description": "Generate a personalised tour itinerary"
		}
	}
}

Response Sections

SectionDescription
categoriesActive event type categories.
featured_toursLast 5 completed tours for the authenticated user; [] for unauthenticated.
generate_tourStatic action descriptor. Use endpoint + method to generate a new tour.

Cache

Cache-Control: private, max-age=0, must-revalidate

Response 429 Too Many Requests

Returned when the caller exceeds the dashboard read budget. Successful and rate-limited responses include the standard X-RateLimit-* headers.


TTS Audio

TTS audio for tour stops is generated asynchronously by a BullMQ worker after the tour is persisted. The POST /api/v1/tours and POST /api/v1/tours/generate endpoints return 202 Accepted immediately — the worker fills in audio_url on each TourStop as it completes.

Feature epic: KUB-188

How it works

  1. POST /api/v1/tours calls buildTour(), which persists the tour + stops and then calls generateTourNarratives(stops, envProvider).
  2. For each stop with a non-empty narrative, generateTourNarratives writes the narrative text to TourStop.narrative and enqueues a TTS job on the tours-tts-audio queue with jobId: "tts-audio:{stopId}" (idempotent — re-runs don't duplicate work).
  3. The worker (handleTtsAudioJob in apps/api/app/lib/queue/workers.ts) calls the configured TTS provider via createTtsAdapter(), uploads the returned audio bytes to Cloudflare R2 via the existing MediaStorageAdapter, and writes the public URL plus provider metadata back to the TourStop row.
  4. Clients poll GET /api/v1/tours/:tourId to discover when audio_url populates on each stop.

Tour stop fields

The GET /api/v1/tours/:tourId response includes the following per-stop fields once the worker has processed the stop:

FieldTypeNotes
audio_urlstring?Public R2 URL once generated. null while pending or if the provider failed terminally.
audio_generated_atstring?ISO 8601 timestamp. null until generated.
tts_voicestring?Provider-specific voice identifier used.
tts_languagestring?BCP-47 language tag.
tts_providerstring?Provider name from the API container's env at job-execution time.

Supported providers

ProviderAPIDefault voiceEnv (API key)
googletexttospeech.googleapis.com/v1/text:synthesizeen-US-Neural2-FGOOGLE_TTS_API_KEY
elevenlabsapi.elevenlabs.io/v1/text-to-speech/{voice_id}21m00Tcm4TlvDq8ikWAM (Rachel)ELEVENLABS_API_KEY
openaiapi.openai.com/v1/audio/speechalloyOPENAI_API_KEY
storiesNo-op (legacy default)
nullNo-op

The provider is selected lazily via TOURS_TTS_PROVIDERcreateTtsAdapter() is called per-job, not at boot. Missing API keys for a non-null provider cause createTtsAdapter() to throw an error naming the missing env var.

Env reference

bash
# Provider dispatcher
TOURS_TTS_PROVIDER=google          # google | elevenlabs | openai | stories | null
TOURS_TTS_VOICE=en-US-Neural2-F    # provider-specific voice id (Google default)
TOURS_TTS_LANGUAGE=en-US           # BCP-47 language tag

# Per-provider API keys (one required when provider != null|stories)
GOOGLE_TTS_API_KEY=
ELEVENLABS_API_KEY=
OPENAI_API_KEY=

Operator surface

The admin app exposes the same configuration in a Filament resource at /admin/tts-settings. The resource shows the persisted "intended" config (editable) and a read-only mirror of the API container's runtime env (values from process.env at request time). Drift between the two is visible on the edit page.

The admin DB is a documentation surface — it does not inject values into the running API container. The API container's env remains the source of truth for runtime behaviour.

Failure modes

  • Provider 4xx/5xx — adapter throws with [Provider]TtsAdapter] TTS request failed | status: NNN | body: …. BullMQ retries with exponential backoff (3 attempts).
  • Empty URL — the worker no-ops; audio_url stays null. No DB write.
  • Adapter throws — same retry path as 4xx/5xx. After 3 attempts the job moves to the failed set, surfaced via Bull Board.
  • Missing API keycreateTtsAdapter() throws synchronously when the provider is first used. Fail-fast on first job.

Backwards compatibility

  • Existing tours created before KUB-188 ship with audio_url: null and never get audio generated unless the user regenerates the tour. A bulk-audio admin action to re-enqueue audio for all existing stops is deferred.
  • TOURS_TTS_PROVIDER=stories (the pre-KUB-188 default) still works — it routes to a no-op adapter, so no provider calls are made.

Built with VitePress