Skip to content

Places API

Discovery, category browse, place detail, full-text search, live photo gallery, and favorite management.

Status

GET /api/v1/categories, GET /api/v1/places, GET /api/v1/places/:id, GET /api/v1/places/:id/augmented-details, GET /api/v1/places/:id/photos, POST /api/v1/places/:id/hours, POST /api/v1/places/:id/digital-assets, POST /api/v1/places/:id/amenities, GET /api/v1/search, and /api/v1/favorites* are live.


Categories

http
GET /api/v1/categories

Full list of place categories. Cacheable — drives the visual category grid on the discovery screen.

Response

json
{
	"data": [
		{
			"id": "1",
			"name": "Beaches",
			"slug": "beaches",
			"icon": null,
			"color": null,
			"place_count": 18
		}
	]
}

The route returns active place categories with a derived place_count based on published places and is cached with Cache-Control: public, max-age=300, stale-while-revalidate=3600.

Related: KUB-25 (US_14), admin KUB-92, KUB-124.


List Places

http
GET /api/v1/places

Paginated place list. Supports category filter, area filter, partial text search, and sort by distance, name, or newest.

Query Parameters

ParameterTypeDescription
pagenumberPage number (default: 1)
limitnumberResults per page (default: 20, max: 100)
categorystringCategory slug filter
areaintegerArea ID filter
qstringPartial name or summary search
sortdistance | name | newestSort order (default: newest)
latnumberLatitude, required together with lng for distance sort
lngnumberLongitude, required together with lat for distance sort

Response

json
{
	"data": [
		{
			"id": "42",
			"name": "Trafalgar Falls",
			"slug": "trafalgar-falls",
			"summary": "Twin waterfalls in the Roseau Valley.",
			"cover_image": null,
			"address": null,
			"city": "Roseau",
			"area": { "id": "2", "name": "Roseau Valley", "slug": "roseau-valley" },
			"category": { "id": "3", "name": "Waterfalls", "slug": "waterfalls" },
			"avg_rating": 4.8,
			"review_count": 12,
			"distance_meters": 1234.5
		}
	],
	"meta": { "page": 1, "limit": 20, "total": 47, "total_pages": 3 }
}

If sort=distance is requested without both lat and lng, the route returns 400 missing_coordinates. The route also returns 400 invalid_area_id when area is not a valid integer.

Related: KUB-26 (US_15), KUB-125, KUB-129.


Place Detail

http
GET /api/v1/places/:id

Full place detail for the place detail screen.

Optional lat and lng query parameters add distance_meters to the response.

If only one coordinate is supplied, or either coordinate is malformed, the endpoint returns 400 Bad Request.

The standard detail route only returns the stored google_place_id in sources. It does not perform on-demand Google discovery.

hours_attribution is managed_locally whenever Kubuli already has persisted hours for the place. It switches to sourced_via_google when Kubuli has no persisted hours rows and the client may need to consult Google data if available.

Response

json
{
	"data": {
		"id": "42",
		"name": "Trafalgar Falls",
		"slug": "trafalgar-falls",
		"summary": "Twin waterfalls in the Roseau Valley.",
		"description": "A scenic twin-waterfall attraction.",
		"place_kind": "poi",
		"cover_image": null,
		"address": null,
		"city": "Roseau",
		"country_code": "DM",
		"website_url": "https://example.com",
		"contact_phone": null,
		"verification_status": "enriched",
		"completeness_score": 70,
		"hours_attribution": "managed_locally",
		"coordinates": { "lat": 15.3167, "lng": -61.3667 },
		"distance_meters": 500,
		"area": { "id": "2", "name": "Roseau Valley", "slug": "roseau-valley" },
		"category": { "id": "3", "name": "Waterfalls", "slug": "waterfalls" },
		"avg_rating": 4.8,
		"review_count": 12,
		"sources": {
			"geoapify_place_id": "geoapify-123",
			"radar_id": "radar-123",
			"google_place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4"
		},
		"operating_hours": [
			{
				"day_of_week": 1,
				"opens_at": "08:00:00",
				"closes_at": "17:00:00",
				"is_closed": false,
				"source_type": "owner"
			}
		]
	}
}

The route returns 404 not_found for unknown or non-numeric place IDs.

Related: KUB-128.


Augmented Place Detail

http
GET /api/v1/places/:id/augmented-details

Returns local place hours together with on-demand Google place details. The server may discover and persist google_place_id, but the Google payload itself is fetched live and is not stored in the database.

This endpoint shares the public feed-style rate limit budget and may return 429 Too Many Requests when that budget is exhausted.

hours_attribution is managed_locally whenever Kubuli already has persisted hours for the place. It switches to sourced_via_google when Kubuli has no persisted hours rows and the client may need to consult Google data if available.

Response

json
{
	"data": {
		"place_id": "42",
		"google_place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4",
		"hours_attribution": "managed_locally",
		"local_hours": [
			{
				"day_of_week": 1,
				"opens_at": "08:00:00",
				"closes_at": "17:00:00",
				"is_closed": false,
				"source_type": "owner"
			}
		],
		"google": {
			"rating": 4.8,
			"photos": []
		}
	}
}

Related: KUB-197.


http
GET /api/v1/places/:id/photos

Paginated photo gallery for a specific place.

Query Parameters

ParameterTypeDescription
pagenumberPage number (default: 1)
limitnumberPhotos per page (default: 20, max: 100)

Response

json
{
	"data": {
		"photos": [
			{
				"id": "10",
				"url": "https://media.kubuli.com/abc-uuid/abc-uuid.jpg",
				"thumbnail_url": "https://media.kubuli.com/abc-uuid/conversions/abc-uuid-thumb.jpg",
				"source_type": "admin_upload",
				"attribution": "Roseau Photo Collective",
				"license": null,
				"original_url": null,
				"alt_text": "Sunset at Scotts Head",
				"mime_type": "image/jpeg",
				"created_at": "2026-05-21T12:00:00.000Z"
			}
		],
		"meta": { "page": 1, "limit": 20, "total": 24, "total_pages": 2 }
	}
}

source_type values: admin_upload, scraped, and community. Pending or rejected community photos do not appear in this response. Related: KUB-126, KUB-151.


Submit Hours

http
POST /api/v1/places/:id/hours
Authorization: Bearer <token>
Content-Type: application/json

Submit or update operating hours for a published place. Only authenticated non-guest app users may call this endpoint. source_type=owner is restricted to curator, admin, or super_admin users. Owner submissions remove same-day UGC rows before inserting the owner row.

After the write, the place is marked pending for asynchronous completeness recalculation in the admin backend. The score is eventually consistent rather than updated inline by the API.

When is_closed is true, omit both opens_at and closes_at.

Request Body

json
{
	"day_of_week": 1,
	"opens_at": "09:00",
	"closes_at": "17:00",
	"is_closed": false,
	"is_overnight": false,
	"source_type": "ugc"
}

Response

json
{
	"data": {
		"success": true
	}
}

Submit Digital Assets

http
POST /api/v1/places/:id/digital-assets
Authorization: Bearer <token>
Content-Type: application/json

Attach menu links, booking URLs, or other supported digital assets to a published place. Only authenticated non-guest app users may call this endpoint. source_type=owner is restricted to curator, admin, or super_admin users and replaces the existing owner asset for the same asset_type before the new row is inserted.

Supported asset_type values: menu_link, menu_json, ticket_link, booking_url, image_url.

asset_url must be an absolute http or https URL. structured_payload must be either a JSON object or null.

The place is marked pending for asynchronous completeness recalculation after the insert.

Request Body

json
{
	"asset_type": "menu_link",
	"asset_url": "https://example.com/menu",
	"source_type": "owner",
	"structured_payload": null
}

Response

json
{
	"data": {
		"id": "8d2f5f66-6f7b-4e0d-9e36-b2133c58f8c7",
		"success": true
	}
}

Submit Amenities

http
POST /api/v1/places/:id/amenities
Authorization: Bearer <token>
Content-Type: application/json

Submit a flat amenity flag map and increment contribution counts for each submitted key. Only authenticated non-guest app users may call this endpoint. Nested objects and arrays are rejected, and submitted keys overwrite existing values for the same place. This endpoint also marks the place pending for asynchronous completeness recalculation.

Request Body

json
{
	"amenity_flags": {
		"wifi": true,
		"parking": true,
		"outdoor_seating": true
	}
}

Response

json
{
	"data": {
		"success": true
	}
}

http
GET /api/v1/search

Cross-entity search across places, events, and stories.

Query Parameters

ParameterTypeDescription
qstringRequired. Search query
typesstringComma-separated entity types (default: all)

Response

json
{
	"data": {
		"places": [
			{
				"id": "42",
				"type": "place",
				"title": "Trafalgar Falls",
				"slug": "trafalgar-falls",
				"thumbnail": null,
				"short_description": "Twin waterfalls in the Roseau Valley"
			}
		],
		"events": [
			{
				"id": "10",
				"type": "event",
				"title": "Independence Day Festival",
				"slug": "independence-day-festival",
				"thumbnail": null,
				"short_description": "Annual festival celebrating Independence Day"
			}
		],
		"stories": [
			{
				"id": "20",
				"type": "story",
				"title": "The Kalinago Heritage",
				"slug": "the-kalinago-heritage",
				"thumbnail": null,
				"short_description": "Exploring the indigenous culture of Dominica"
			}
		]
	},
	"meta": { "q": "heritage", "types": ["places", "events", "stories"] }
}

The route returns up to 10 results per type and truncates long excerpts to 120 characters.

Related: KUB-127.


Favorites

Add Favorite

http
POST /api/v1/favorites
Authorization: Bearer <token>
Content-Type: application/json
json
{
	"entity_type": "place",
	"entity_id": "place_123"
}

Response: 201 Created with favorite object.


Remove Favorite

http
DELETE /api/v1/favorites/:id
Authorization: Bearer <token>
http
DELETE /api/v1/favorites/{entity_type}/{entity_id}
Authorization: Bearer <token>

Response: 204 No Content.


List Favorites

http
GET /api/v1/favorites?entity_type=place
Authorization: Bearer <token>
ParameterTypeDescription
entity_typeplace | event | storyFilter by type (optional)
limitnumber(default: 20)
cursorstringCursor for next page

Response: paginated list of favorited entities. Related: KUB-130.


Places Dashboard

http
GET /api/v1/places/dashboard

Returns place categories and published places within a configurable radius in a single aggregate response.

Authentication

Optional. Signed-in callers may authenticate with either a bearer JWT or a valid Better Auth session cookie to receive the higher dashboard rate-limit budget.

Rate Limits

Caller typeRequests / minute
Unauthenticated30
Authenticated120

Query Parameters

ParameterTypeRequiredDefaultDescription
latnumberNoLatitude. Must be supplied together with lng.
lngnumberNoLongitude. Must be supplied together with lat.
radiusintegerNo32187Search radius in metres (default ≈ 20 miles). Must be a positive integer.

Response 200 OK

json
{
	"data": {
		"categories": [
			{
				"id": "1",
				"name": "Beaches",
				"slug": "beaches",
				"icon": null,
				"color": null,
				"place_count": 18
			}
		],
		"places": [
			{
				"id": "201",
				"name": "Trafalgar Falls",
				"slug": "trafalgar-falls",
				"summary": "Twin waterfalls in the Roseau Valley",
				"city": "Roseau",
				"area": null,
				"category": null,
				"avg_rating": null,
				"distance_meters": 1234.5
			}
		]
	}
}

When lat+lng are absent the places array is empty; categories is always populated.

Response 400 Bad Request

Returned when only one of lat/lng is supplied, or radius is not a positive integer.

Response 429 Too Many Requests

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

Built with VitePress