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
GET /api/v1/categoriesFull list of place categories. Cacheable — drives the visual category grid on the discovery screen.
Response
{
"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
GET /api/v1/placesPaginated place list. Supports category filter, area filter, partial text search, and sort by distance, name, or newest.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
page | number | Page number (default: 1) |
limit | number | Results per page (default: 20, max: 100) |
category | string | Category slug filter |
area | integer | Area ID filter |
q | string | Partial name or summary search |
sort | distance | name | newest | Sort order (default: newest) |
lat | number | Latitude, required together with lng for distance sort |
lng | number | Longitude, required together with lat for distance sort |
Response
{
"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
GET /api/v1/places/:idFull 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
{
"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
GET /api/v1/places/:id/augmented-detailsReturns 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
{
"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.
Place Photo Gallery
GET /api/v1/places/:id/photosPaginated photo gallery for a specific place.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
page | number | Page number (default: 1) |
limit | number | Photos per page (default: 20, max: 100) |
Response
{
"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
POST /api/v1/places/:id/hours
Authorization: Bearer <token>
Content-Type: application/jsonSubmit 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
{
"day_of_week": 1,
"opens_at": "09:00",
"closes_at": "17:00",
"is_closed": false,
"is_overnight": false,
"source_type": "ugc"
}Response
{
"data": {
"success": true
}
}Submit Digital Assets
POST /api/v1/places/:id/digital-assets
Authorization: Bearer <token>
Content-Type: application/jsonAttach 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
{
"asset_type": "menu_link",
"asset_url": "https://example.com/menu",
"source_type": "owner",
"structured_payload": null
}Response
{
"data": {
"id": "8d2f5f66-6f7b-4e0d-9e36-b2133c58f8c7",
"success": true
}
}Submit Amenities
POST /api/v1/places/:id/amenities
Authorization: Bearer <token>
Content-Type: application/jsonSubmit 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
{
"amenity_flags": {
"wifi": true,
"parking": true,
"outdoor_seating": true
}
}Response
{
"data": {
"success": true
}
}Full-Text Search
GET /api/v1/searchCross-entity search across places, events, and stories.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Required. Search query |
types | string | Comma-separated entity types (default: all) |
Response
{
"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
POST /api/v1/favorites
Authorization: Bearer <token>
Content-Type: application/json{
"entity_type": "place",
"entity_id": "place_123"
}Response: 201 Created with favorite object.
Remove Favorite
DELETE /api/v1/favorites/:id
Authorization: Bearer <token>DELETE /api/v1/favorites/{entity_type}/{entity_id}
Authorization: Bearer <token>Response: 204 No Content.
List Favorites
GET /api/v1/favorites?entity_type=place
Authorization: Bearer <token>| Parameter | Type | Description |
|---|---|---|
entity_type | place | event | story | Filter by type (optional) |
limit | number | (default: 20) |
cursor | string | Cursor for next page |
Response: paginated list of favorited entities. Related: KUB-130.
Places Dashboard
GET /api/v1/places/dashboardReturns 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 type | Requests / minute |
|---|---|
| Unauthenticated | 30 |
| Authenticated | 120 |
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
lat | number | No | — | Latitude. Must be supplied together with lng. |
lng | number | No | — | Longitude. Must be supplied together with lat. |
radius | integer | No | 32187 | Search radius in metres (default ≈ 20 miles). Must be a positive integer. |
Response 200 OK
{
"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.