This document defines the canonical HTTP API surface between the Server layer and any client (web UI, HTMX interactions, CLI, future SPA/mobile app).
The goal is to keep the API small, consistent, and plugin-centric:
- clients should not need to know plugin-specific auth quirks
- the server should expose a stable JSON contract even if internals change
- route shape should minimize change amplification across frontend, server, and docs
This is an internal product API, not a public third-party integration surface.
Borrowing from A Philosophy of Software Design, the API should be a deep module:
a small number of obvious endpoints hiding the messy details of OAuth, file imports, sync cursors, enrichment status, and per-plugin differences.
We group most platform operations under:
/api/v1/plugins/{plugin}/...
This keeps the interface simple for clients:
- the client identifies the plugin once
- the same route family works for Spotify, YouTube, Netflix, etc.
- auth type differences are pushed downward into server/core/plugin implementations
Example:
/connect/{plugin}/login/sync/{plugin}/import/{plugin}/disconnect/{plugin}
This works, but it leaks implementation categories into the interface and increases cognitive load. A plugin-centric API is easier to discover and document.
All JSON endpoints live under:
/api/v1
- Browser clients use the app session cookie
- Mutating requests require CSRF protection
- Future programmatic clients may use bearer tokens, but session auth is the MVP default
Requests and responses use JSON unless explicitly documented otherwise.
Content-Type: application/json
Accept: application/jsonFile uploads use multipart/form-data.
All timestamps are RFC3339 UTC strings.
Example:
"2026-03-11T16:00:00Z"Collection endpoints use cursor or offset/limit depending on access pattern:
- timeline-style feeds:
limit+offsetfor MVP simplicity - plugin sync state: no pagination
- future large collections may adopt cursor pagination
All non-2xx JSON responses should use a consistent envelope:
{
"error": {
"code": "rate_limit",
"message": "Spotify rate limit hit",
"details": {
"retry_after_seconds": 30
}
}
}Common error codes:
unauthorizedforbiddennot_foundvalidation_errorrate_limitauth_expiredpermission_deniedfile_parse_errorupstream_errorinternal_error
For single resources:
{
"data": { ... }
}For collections:
{
"data": [ ... ],
"meta": {
"limit": 50,
"offset": 0,
"total": 231
}
}For actions:
{
"data": {
"status": "ok"
}
}Not every route in the app should be part of the JSON API.
These render pages or perform OAuth redirects:
/login/auth/google/login/auth/google/callback/auth/github/login/auth/github/callback/share/{slug}/settings/settings/share
These power authenticated interactions and future non-HTML clients:
/api/v1/session/api/v1/plugins/api/v1/plugins/{plugin}/api/v1/plugins/{plugin}/connect/api/v1/plugins/{plugin}/import/api/v1/plugins/{plugin}/disconnect/api/v1/plugins/{plugin}/sync/api/v1/timeline/api/v1/insights/*/api/v1/share-profile/api/v1/items/{id}/privacy
This split keeps page rendering concerns out of the client API while still allowing the web UI to call a clean JSON layer.
The main resources exposed to clients are:
- session — who is logged in
- plugins — connection state and sync state per platform
- timeline items — normalized consumed media
- insights — aggregates derived from media items and tags
- share profile — public profile configuration
Returns the currently authenticated user and high-level app state.
{
"data": {
"user": {
"id": "usr_123",
"email": "user@example.com",
"display_name": "Estifanos",
"avatar_url": "https://...",
"profile_slug": "estifanos"
}
}
}Logs the user out.
{
"data": {
"status": "logged_out"
}
}Plugins are the most important API surface because they unify OAuth sources, file imports, and future API-key integrations.
{
"name": "spotify",
"display_name": "Spotify",
"auth_type": "oauth",
"status": "connected",
"enabled": true,
"connected": true,
"last_synced_at": "2026-03-11T15:20:00Z",
"error_message": null,
"sync": {
"status": "idle",
"last_run_status": "success"
}
}List all registered plugins with per-user state.
{
"data": [
{
"name": "spotify",
"display_name": "Spotify",
"auth_type": "oauth",
"status": "connected",
"enabled": true,
"connected": true,
"last_synced_at": "2026-03-11T15:20:00Z",
"error_message": null
},
{
"name": "netflix",
"display_name": "Netflix",
"auth_type": "file_import",
"status": "disconnected",
"enabled": true,
"connected": false,
"last_synced_at": null,
"error_message": null
}
]
}Returns a single plugin's state and capabilities.
{
"data": {
"name": "youtube",
"display_name": "YouTube",
"auth_type": "oauth",
"status": "connected",
"connected": true,
"capabilities": {
"can_connect": true,
"can_disconnect": true,
"can_import": false,
"can_sync": true,
"supports_incremental_sync": true
}
}
}Starts an OAuth connection flow.
For browser clients, this returns a redirect URL rather than forcing route knowledge into the frontend.
{
"data": {
"redirect_url": "https://accounts.spotify.com/authorize?..."
}
}- Only valid for
auth_type = oauth - The actual callback target remains server-owned
- The client should redirect the browser to
redirect_url
Uploads a file for file-import plugins.
multipart/form-data
Fields:
file— required
{
"data": {
"plugin": "netflix",
"status": "connected",
"imported": true
}
}- For MVP, import and sync can be the same operation
- Keeping a separate import endpoint leaves room for validation-only flows later
Disconnects a plugin and deletes stored credentials.
{
"data": {
"plugin": "spotify",
"status": "disconnected"
}
}Triggers a sync for a connected plugin.
{
"data": {
"plugin": "spotify",
"status": "success",
"items_added": 42,
"items_skipped": 10,
"items_updated": 3,
"enrichment_status": "completed",
"last_synced_at": "2026-03-11T16:10:00Z"
}
}successpartialrate_limitedfailed
{
"error": {
"code": "rate_limit",
"message": "Plugin sync is temporarily rate-limited",
"details": {
"retry_after_seconds": 900
}
}
}Returns recent sync runs for this plugin.
{
"data": [
{
"started_at": "2026-03-11T16:00:00Z",
"completed_at": "2026-03-11T16:00:18Z",
"status": "success",
"items_added": 42,
"items_skipped": 10,
"items_updated": 3,
"error_code": null,
"error_message": null
}
]
}The timeline is the normalized feed of consumed media.
{
"id": "itm_123",
"platform": "spotify",
"type": "music",
"title": "Breathe",
"creator": "Pink Floyd",
"consumed_at": "2026-03-10T22:04:00Z",
"duration_seconds": 163,
"time_spent_seconds": 163,
"url": "https://open.spotify.com/track/...",
"external_id": "spotify:track:...",
"enrichment_status": "enriched",
"private": false,
"tags": [
{
"name": "progressive-rock",
"category": "genre",
"source": "spotify",
"confidence": null
},
{
"name": "melancholic",
"category": "mood",
"source": "llm",
"confidence": 0.82
}
]
}Returns paginated items for the dashboard timeline.
from— optional RFC3339 timestampto— optional RFC3339 timestampplatform— optional repeated or comma-separated filtertype— optional repeated or comma-separated filterq— optional full-text query over title/creatorlimit— default50, max100offset— default0
GET /api/v1/timeline?platform=spotify,youtube&type=music,video&limit=50&offset=0{
"data": [
{
"id": "itm_123",
"platform": "spotify",
"type": "music",
"title": "Breathe",
"creator": "Pink Floyd",
"consumed_at": "2026-03-10T22:04:00Z",
"enrichment_status": "enriched",
"private": false,
"tags": ["progressive-rock", "melancholic"]
}
],
"meta": {
"limit": 50,
"offset": 0,
"total": 231
}
}Sets whether an item should be excluded from sharing.
{
"private": true
}{
"data": {
"id": "itm_123",
"private": true
}
}Insights are aggregates computed from normalized items and tags.
Returns top-level dashboard numbers.
{
"data": {
"total_items": 4218,
"total_time_spent_seconds": 948322,
"top_platform": "spotify",
"top_type": "music"
}
}{
"data": [
{
"platform": "spotify",
"type": "music",
"count": 1880,
"total_duration_seconds": 502311
},
{
"platform": "youtube",
"type": "video",
"count": 740,
"total_duration_seconds": 183000
}
]
}Returns aggregate tag counts.
category— optional (genre,topic,mood,format)from/to— optional time windowlimit— default20
{
"data": [
{
"name": "rock",
"category": "genre",
"count": 184
},
{
"name": "science",
"category": "topic",
"count": 91
}
]
}Returns time-bucketed consumption data for charts.
bucket—day,week, ormonthfrom/to— optionalplatform— optional filtertype— optional filter
{
"data": [
{
"bucket_start": "2026-03-01T00:00:00Z",
"count": 42,
"time_spent_seconds": 17280
},
{
"bucket_start": "2026-03-02T00:00:00Z",
"count": 35,
"time_spent_seconds": 14400
}
]
}This API manages the authenticated user's public profile configuration.
{
"enabled": true,
"profile_slug": "estifanos",
"excluded_platforms": ["netflix"],
"excluded_tags": ["romance"],
"blocks": [
{
"type": "top_genres",
"enabled": true,
"time_range": "30d"
},
{
"type": "top_creators",
"enabled": true,
"time_range": "30d",
"count": 10,
"platforms": ["spotify", "youtube"]
}
]
}Returns the current user's share configuration.
Replaces the current user's share configuration.
{
"enabled": true,
"excluded_platforms": ["netflix"],
"excluded_tags": ["romance"],
"blocks": [
{
"type": "top_genres",
"enabled": true,
"time_range": "30d"
}
]
}{
"data": {
"enabled": true,
"profile_slug": "estifanos"
}
}Returns the exact block data that would render on the public profile.
{
"data": {
"blocks": [
{
"type": "top_genres",
"title": "Top Genres",
"items": [
{ "name": "rock", "count": 45 },
{ "name": "electronic", "count": 20 }
]
}
]
}
}| Status | When |
|---|---|
200 OK |
Successful read or action |
201 Created |
New server-side resource created |
204 No Content |
Delete/logout with no body |
400 Bad Request |
Invalid request shape or params |
401 Unauthorized |
No valid session |
403 Forbidden |
Valid session, insufficient access |
404 Not Found |
Resource or plugin not found |
409 Conflict |
Action invalid in current state |
422 Unprocessable Entity |
Validation error for syntactically valid input |
429 Too Many Requests |
App or upstream rate limit |
500 Internal Server Error |
Unexpected server failure |
502 Bad Gateway |
Upstream platform failure surfaced by server |
Some existing docs show route examples such as:
/connect/{plugin}/login/sync/{plugin}/settings/share/.../api/items/{id}/private
Those examples are useful, but this file is the canonical API surface for the client/server boundary going forward. If implementation or other docs diverge, this file should be treated as the source of truth and the others should be aligned.
A good API should reduce cognitive load, not mirror every internal component.
This surface stays deliberately compact by:
- exposing plugin operations instead of provider-specific flows
- exposing insights as aggregates rather than leaking query internals
- exposing share profile as one document-shaped resource instead of many tiny endpoints
- separating HTML routes from JSON routes so clients know which layer they are using
If we later add background jobs, new plugins, or a SPA frontend, clients should still mostly interact with the same resource shapes defined here.