Reviews API Reference
Base paths: /api/v1/reviews, /api/v1/tours/:tourId/stops/:stopId/reviewsAuthentication: All endpoints require app-user authentication. Protected routes accept Authorization: Bearer <jwt> or a valid Better Auth session cookie. Feature epic: KUB-181 Tours and Itineraries See also: Tours API
Status
Implemented for the current V1 route surface. Aggregates, Google badge enrichment, and richer moderation metadata remain planned follow-up work.
Overview
The Reviews API provides a polymorphic in-app review system for tour stops backed by canonical reviewable_type values of place and event. Each user may submit one in-app review per entity.
External-source reviews remain a separate enrichment concern and do not overwrite in-app reviews.
Review Status Lifecycle
published → flagged
↓ ↓
hidden published | hidden | rejectedIn-app reviews are published immediately. A moderation flag moves the review into flagged, where an admin can republish it, hide it, or reject it. Other statuses still exist in the schema for moderation and future ingestion workflows, but they are not the primary public in-app path today.
Source Values
| Value | Description |
|---|---|
in_app | Submitted by a Kubuli user |
google | Imported from Google Places via background enrichment |
tripadvisor | Imported from TripAdvisor (future) |
yelp | Imported from Yelp (future) |
manual | Reserved for future/manual backfill workflows |
Endpoints
Create a Review
POST /api/v1/tours/:tourId/stops/:stopId/reviewsAuth: Required (tour owner only) Description: Submits an in-app review for the place or event associated with the tour stop.
Request body:
{
"rating": 4,
"title": "Fresh and affordable",
"body": "Great produce and friendly vendors. Highly recommended for early mornings.",
"trip_type": "solo",
"visit_date": "2025-07-01",
"is_anonymous": false,
"aspects": [
{ "aspect": "value", "rating": 5 },
{ "aspect": "ambiance", "rating": 4 },
{ "aspect": "service", "rating": 4 }
]
}Both camelCase and snake_case request fields are accepted for tripType / trip_type, visitDate / visit_date, and isAnonymous / is_anonymous.
| Field | Type | Required | Notes |
|---|---|---|---|
rating | integer | Yes | Must be an integer between 1 and 5 |
title | string | No | Optional trimmed string, max 100 characters |
body | string | No | Optional trimmed string |
trip_type | string | No | Optional free-form trip label, max 50 characters |
visit_date | date | No | ISO 8601 date string |
is_anonymous | boolean | No | Optional boolean |
aspects | array | No | Valid entries are kept; invalid ones are ignored |
Response 201:
{
"data": {
"reviewId": "clx..."
}
}Error responses:
| Status | Code | Description |
|---|---|---|
| 400 | invalid_body | Request body must be valid JSON |
| 404 | not_found | Tour or stop was not found for the current user |
| 409 | review_exists | User has already reviewed this entity with source=in_app |
| 422 | validation_error | Rating is invalid or the payload is otherwise unusable |
List Reviews for a Stop
GET /api/v1/tours/:tourId/stops/:stopId/reviewsAuth: Required (tour owner only) Description: Returns published, non-deleted reviews for the entity associated with the stop.
Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit | integer | 20 | Max 100 |
cursor | string | — | Opaque next cursor |
Response 200:
{
"data": [
{
"id": "clx...",
"rating": 4,
"title": "Fresh and affordable",
"body": "Great produce and friendly vendors.",
"trip_type": "solo",
"visit_date": "2025-07-01",
"aspect_ratings": [],
"media": []
}
],
"meta": {
"cursor": null
}
}The current handler filters by published status, excludes soft-deleted rows, and uses the stop's resolved entity linkage. Review aggregates and Google badge enrichment are planned follow-up work and are not yet present in the live list response.
Vote on a Review
POST /api/v1/reviews/:reviewId/votesAuth: Required Description: Records a helpful or not_helpful vote for a published review.
Request body:
{
"vote_type": "helpful"
}The canonical public path /api/v1/reviews/:reviewId/votes accepts both voteType and vote_type.
The legacy compatibility alias /api/v1/tours/reviews/:reviewId/votes is also still accepted and now accepts both voteType and vote_type.
| Field | Type | Required | Notes |
|---|---|---|---|
voteType or vote_type | string | Yes | helpful or not_helpful |
Response 200:
{
"data": {
"reviewId": "clx...",
"voteType": "helpful"
}
}Error responses:
| Status | Code | Description |
|---|---|---|
| 404 | not_found | Review does not exist or is not published |
| 400 | invalid_body | Request body must be valid JSON |
| 422 | validation_error | Vote payload is invalid |
| 429 | rate_limited | Too many write actions for the current user |
Flag a Review for Moderation
POST /api/v1/reviews/:reviewId/flagAuth: Required Description: Flags a published review for admin moderation.
The canonical public path is /api/v1/reviews/:reviewId/flag. The legacy compatibility alias /api/v1/tours/reviews/:reviewId/flag is also still accepted.
Flag submissions share the same per-user write rate limit as votes.
Request body:
{
"reason": "spam",
"notes": "Looks fake."
}| Field | Type | Required | Notes |
|---|---|---|---|
reason | string | Yes | Any non-empty string |
notes | string | No | Optional free text |
Response 201:
{
"data": {
"flagId": "clx...",
"reviewId": "clx..."
}
}Error responses:
| Status | Code | Description |
|---|---|---|
| 404 | not_found | Review does not exist or is not visible |
| 400 | invalid_body | Request body must be valid JSON |
| 422 | validation_error | Reason is blank |
| 429 | rate_limited | Too many write actions for the current user |
Review Aggregates (Planned)
Denormalized review aggregates and richer per-stop review summaries are planned follow-up work. They are not yet included in the live review list or tour preview responses.
External Enrichment (Google Badge, Planned)
External review enrichment remains planned. When added, Google-sourced reviews will remain separate from in-app reviews and will surface as a badge or summary rather than overwriting user-submitted content.
Admin Moderation
Review moderation (publish, hide, reject) is available via the admin CMS (apps/admin). The current CMS does not create manual reviews, and there is no public response-writing endpoint in the current V1 API.
Common Error Responses
{
"error": {
"code": "not_found",
"message": "Not found",
"status": 404
}
}| Status | Meaning |
|---|---|
| 401 | Unauthorized — missing or invalid bearer token or session cookie |
| 404 | Not found |
| 409 | Conflict — duplicate in-app review |
| 422 | Unprocessable entity — validation failure |
| 429 | Rate limit exceeded |