Feed API
Global mixed-content feed returning published events, places, and stories in a single ranked response. Authentication is optional.
Status
Implemented — GET /api/v1/feed is live. Tracked under KUB-174.
Get Feed
GET /api/v1/feedReturns a mixed-content feed built from published events, places, and stories. Unauthenticated and guest callers receive the shared global ranking with the anonymous rate limit; signed-in callers receive the same ranking with the higher authenticated budget.
Authentication
Optional. Pass either a Bearer token in the Authorization header or a valid Better Auth session cookie to receive an authenticated response and the higher rate limit.
Rate Limits
| Caller type | Requests / minute |
|---|---|
| Unauthenticated | 30 |
| Authenticated | 120 |
Rate limit state is communicated via response headers (see below).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 20 | Number of items to return. Maximum 100. |
types | string | — | Comma-separated list of content types to include: event, place, story. Omit to include all. |
Response Headers
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current window. |
X-RateLimit-Remaining | Requests remaining in the current window. |
X-RateLimit-Reset | Unix epoch seconds when the current window resets. |
Response 200 OK
{
"data": [
{
"id": "event:101",
"type": "event",
"title": "Carnival Road March",
"subtitle": "Annual Dominica Carnival celebration",
"image_url": null,
"deeplink": "kubuli://events/101",
"published_at": "2025-02-10T08:00:00.000Z",
"rank": 1,
"data": {
"id": "101",
"slug": "carnival-road-march",
"title": "Carnival Road March",
"starts_at": "2025-02-10T08:00:00.000Z"
},
"context": { "location": "Roseau" }
},
{
"id": "place:201",
"type": "place",
"title": "Trafalgar Falls",
"subtitle": "Twin waterfalls in the Roseau Valley",
"image_url": null,
"deeplink": "kubuli://places/201",
"published_at": "2025-01-22T09:00:00.000Z",
"rank": 2,
"data": {
"id": "201",
"slug": "trafalgar-falls",
"name": "Trafalgar Falls",
"place_kind": "poi"
},
"context": { "location": "Roseau Valley", "kind": "poi" }
}
],
"meta": {
"cursor": null
}
}Feed Item Schema
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string | No | Unique item identifier. |
type | event | place | story | No | Content type. |
title | string | No | Display title. |
subtitle | string | Yes | Secondary display text. |
image_url | string | Yes | Absolute URL to cover image. |
deeplink | string | Yes | App deeplink URI for navigation. |
published_at | string (ISO 8601) | Yes | Publication timestamp. |
rank | integer | No | Sort rank within the feed response. |
data | object | No | Original source item payload. |
context | object | No | Type-specific metadata (arbitrary keys). |
Response 429 Too Many Requests
Returned when the caller exceeds their rate limit. Includes the standard X-RateLimit-* headers so clients can back off correctly.
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Please slow down.",
"status": 429
}
}Feed Dashboard
GET /api/v1/feed/dashboardReturns five aggregate sections for the main discovery dashboard in a single 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 | Description |
|---|---|---|---|
lat | number | No | Latitude. Required together with lng to populate nearby_places. |
lng | number | No | Longitude. Required together with lat. |
Response 200 OK
{
"data": {
"popular_categories": [
{
"id": "1",
"name": "Beaches",
"slug": "beaches",
"icon": null,
"color": null,
"place_count": 18
}
],
"nearby_places": [
{
"id": "201",
"name": "Trafalgar Falls",
"slug": "trafalgar-falls",
"avg_rating": null,
"distance_meters": 500.0
}
],
"popular_places": [
{
"id": "201",
"name": "Trafalgar Falls",
"slug": "trafalgar-falls",
"avg_rating": null,
"distance_meters": null
}
],
"upcoming_events": [
{
"id": "101",
"title": "Carnival Road March",
"slug": "carnival-road-march",
"starts_at": "2025-02-10T08:00:00.000Z",
"is_all_day": false,
"timezone": "America/Dominica"
}
],
"relevant_stories": [
{
"id": "301",
"title": "The History of the Kalinago People",
"slug": "history-of-the-kalinago-people",
"published_at": "2025-02-10T08:00:00.000Z",
"read_time_minutes": 3,
"culture_topic": {
"id": "21",
"name": "Dominica Heritage",
"slug": "dominica-heritage"
}
}
]
}
}Response Sections
| Section | Limit | Description |
|---|---|---|
popular_categories | 6 | Place categories ordered by place count descending. |
nearby_places | 6 | Published places nearest to the supplied coordinates; empty array when coords absent. |
popular_places | 6 | Published places ordered by average rating and review count descending. |
upcoming_events | 6 | Published events ordered by start time ascending. |
relevant_stories | 5 | Published culture stories ordered by display order then publication date. |
Response 400 Bad Request
Returned when only one of lat/lng is supplied.
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.
Notes
- The feed is assembled from published events, places, and stories in the canonical content tables.
- Signed-in users receive the higher authenticated budget. Guest sessions remain on the anonymous budget.
- Cursor-based pagination is reserved for future use;
meta.cursoris alwaysnulland nocursorquery parameter is supported yet. - The
typesfilter silently ignores unrecognised type values; if all values are unrecognised the filter is not applied.