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 | exerciseTour Status Lifecycle
draft → active → completed → archivedEndpoints
Generate a Tour
POST /api/v1/tours
POST /api/v1/tours/generateAuth: 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:
{
"tour_type": "food",
"duration_minutes": 90,
"start_location": {
"lat": 15.301,
"lng": -61.388
}
}Both snake_case and camelCase request fields are accepted.
| Field | Type | Required | Notes |
|---|---|---|---|
tour_type | string (enum) | Yes | One of the tour type values above |
duration_minutes | integer | Yes | Must be between 30 and 480 |
start_location | object | No | { lat, lng } |
Response 202:
{
"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:
| Status | Code | Description |
|---|---|---|
| 422 | validation_error | Invalid tour_type, invalid duration, or malformed JSON |
| 429 | rate_limited | More than 5 tour generations in the past hour |
| 503 | insufficient_stops | Not enough published content matches the tour type and location |
List Tours
GET /api/v1/toursAuth: 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:
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit | integer | 20 | Max 50 |
cursor | string | — | Opaque next cursor |
status | string | — | Optional status filter |
Response 200:
{
"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
GET /api/v1/tours/:tourIdAuth: Required (owner only)
Response 200: Full persisted tour object with ordered stops and per-stop media.
Error responses:
| Status | Code | Description |
|---|---|---|
| 404 | not_found | Tour not found |
Get Tour Preview
GET /api/v1/tours/:tourId/previewAuth: 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:
{
"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
GET /api/v1/tours/:tourId/stops/:stopId/directionsAuth: 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:
{
"data": null
}When directions exist, the route returns the persisted directions_from_prev JSON payload as-is.
Complete a Tour
POST /api/v1/tours/:tourId/completeAuth: Required (owner only) Description: Marks the tour as completed.
Request body: Empty
Response 200:
{
"data": {
"tourId": "clx...",
"status": "completed"
}
}Submitting the request again for an already completed tour returns the same 200 response.
Queue Video Generation
POST /api/v1/tours/:tourId/video/generateAuth: Required (owner only) Description: Returns a queued placeholder state for a completed tour. Actual background enqueue behavior remains follow-up work.
Response 202:
{
"data": {
"tourId": "clx...",
"videoStatus": "queued"
}
}If the tour has not been completed yet, the route returns 422 validation_error.
Get Video Status
GET /api/v1/tours/:tourId/video/statusAuth: Required (owner only)
Response 200:
{
"data": {
"tourId": "clx...",
"videoStatus": "done",
"videoUrl": "https://r2.cloudflarestorage.com/..."
}
}Current videoStatus values are done and not_started.
Related Review Endpoints
Tour stop reviews and review moderation actions are documented in Reviews API.
POST /api/v1/tours/:tourId/stops/:stopId/reviewsGET /api/v1/tours/:tourId/stops/:stopId/reviewsPOST /api/v1/reviews/:reviewId/votesPOST /api/v1/reviews/:reviewId/flag
Legacy compatibility aliases also remain supported for older clients:
POST /api/v1/tours/reviews/:reviewId/votesPOST /api/v1/tours/reviews/:reviewId/flag
Common Error Responses
All errors follow the standard API error envelope:
{
"error": {
"code": "not_found",
"message": "Not found",
"status": 404
}
}| Status | Meaning |
|---|---|
| 401 | Unauthorized — missing or invalid bearer token or session cookie |
| 404 | Not found |
| 422 | Unprocessable entity — validation failure |
| 429 | Rate limit exceeded |
| 503 | Service unavailable — e.g. no matching stops found |
Tours Dashboard
GET /api/v1/tours/dashboardReturns 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
{
"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
| Section | Description |
|---|---|
categories | Active event type categories. |
featured_tours | Last 5 completed tours for the authenticated user; [] for unauthenticated. |
generate_tour | Static 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
POST /api/v1/tourscallsbuildTour(), which persists the tour + stops and then callsgenerateTourNarratives(stops, envProvider).- For each stop with a non-empty narrative,
generateTourNarrativeswrites the narrative text toTourStop.narrativeand enqueues a TTS job on thetours-tts-audioqueue withjobId: "tts-audio:{stopId}"(idempotent — re-runs don't duplicate work). - The worker (
handleTtsAudioJobinapps/api/app/lib/queue/workers.ts) calls the configured TTS provider viacreateTtsAdapter(), uploads the returned audio bytes to Cloudflare R2 via the existingMediaStorageAdapter, and writes the public URL plus provider metadata back to theTourStoprow. - Clients poll
GET /api/v1/tours/:tourIdto discover whenaudio_urlpopulates 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:
| Field | Type | Notes |
|---|---|---|
audio_url | string? | Public R2 URL once generated. null while pending or if the provider failed terminally. |
audio_generated_at | string? | ISO 8601 timestamp. null until generated. |
tts_voice | string? | Provider-specific voice identifier used. |
tts_language | string? | BCP-47 language tag. |
tts_provider | string? | Provider name from the API container's env at job-execution time. |
Supported providers
| Provider | API | Default voice | Env (API key) |
|---|---|---|---|
google | texttospeech.googleapis.com/v1/text:synthesize | en-US-Neural2-F | GOOGLE_TTS_API_KEY |
elevenlabs | api.elevenlabs.io/v1/text-to-speech/{voice_id} | 21m00Tcm4TlvDq8ikWAM (Rachel) | ELEVENLABS_API_KEY |
openai | api.openai.com/v1/audio/speech | alloy | OPENAI_API_KEY |
stories | No-op (legacy default) | — | — |
null | No-op | — | — |
The provider is selected lazily via TOURS_TTS_PROVIDER — createTtsAdapter() 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
# 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_urlstaysnull. 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 key —
createTtsAdapter()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: nulland 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.