Music taste, as a module

taste gives your product a user’s resolved music taste without you building ingestion, consent, or PII custody. One hosted connect flow handles per-source consent and import; one read API returns the user’s resolved cluster_id profile. Reads happen in the user’s own browser with the user’s own bearer token — your servers never touch their PII.

The full API surface is published as a frozen, catalogue-numbered contract at /contracts/v1. What a consumer app can reach is exactly what is listed there — CORS allowlists and token scope enforce it.

Integration walkthrough

Four steps from "user wants to connect Last.fm" to "your frontend has their resolved artist profile". All wire shapes are from contracts/published.yaml v1, last updated 2026-07-07.

Step 1 — send the user to the connect flow

Redirect the user to taste’s hosted connect page. taste validates the return= URL against your per-consumer allowlist before the page renders — open-redirect is impossible by construction.

GET https://taste.hosaka.fm/connect?service=lastfm&return=https://your-app.example/callback

# service: lastfm | discogs
#   discogs = public collection by username (no OAuth required)
# return: your URL — validated against your consumer allowlist before the page renders

Step 2 — taste handles consent, queues the import, redirects back

taste shows the user a per-source GDPR consent step and a username field. The user submits; taste records consent, queues the import, mints a single-use connect code, and 302s back to your return= URL with the code in the query string.

302  →  https://your-app.example/callback?taste_code=<single-use code>

# code is single-use and expires 60 seconds after mint
# consent is recorded PER SOURCE before the import begins

Step 3 — exchange the code for a bearer token

POST the code to /api/v1/me/token. This is the only time the token plaintext crosses the wire. The response also contains sub — a pairwise pseudonymous subject identifier unique to your app.

POST /api/v1/me/token
Content-Type: application/json

{ "code": "<taste_code from step 2>" }

──── 200 response ────────────────────────────────────────
{
  "token":      "taste_v1_…",   // plaintext, returned exactly once
  "expires_at": "2026-10-06T…", // ISO-8601; 90-day sliding expiry, renews on use
  "scopes":     ["profile:read"],
  "sub":        "sub_v1_…"      // pairwise pseudonymous subject — your per-app primary key
                                 // the SAME person has a DIFFERENT sub in every other app
                                 // do not attempt cross-app correlation
                                 // never use email as an account key
}

──── 400 ─────────────────────────────────────────────────
{ "error": { "code": "code_invalid" } }
// single reason for unknown/expired/already-claimed — no oracle

Step 4 — read the user’s profile

Your frontend calls GET /api/v1/me/profile with the bearer token in the Authorization header. The token IS the subject selector — no person ID in the path. Every response is Cache-Control: no-store, including 401s.

GET /api/v1/me/profile
Authorization: Bearer taste_v1_…

──── 200 response ────────────────────────────────────────
{
  "connected_sources": {
    "lastfm":  true,   // or false
    "discogs": false,
    "upload":  false   // upload is operator-mediated only — always false for public connect joiners
  },
  "connected_source_count": 1,
  "resolved_cluster_ids": [
    "…",               // canonical + unverified tiers only
    "…"                // unmatched items excluded (never padded into this list)
  ]
}

──── 401 ─────────────────────────────────────────────────
{ "error": { "code": "token_invalid" | "token_expired" | "token_revoked" } }
// Cache-Control: no-store on 401s too

Guarantees

These are structural properties of the contract, not configuration options.

PropertyDetail
Token format: taste_v1_90-day sliding expiry (renews on use), scope profile:read only, revocable at any time via DELETE /api/v1/me/token. Hashed at rest — plaintext returned exactly once at POST /me/token, never again.
Cache-Control: no-store everywhereEvery response on the /api/v1/me/* surface — including 401s — carries Cache-Control: no-store. A user’s profile can never sit in an edge cache, even if a network appliance tries.
Pairwise subject: sub_v1_The subject identifier in the token exchange response is HMAC-derived per (person, consumer). The same person has a different sub_v1_ in your app than in any other consumer. Cross-app correlation is impossible by construction. Never key accounts on email — email is not an account key.
CORS exact-origin allowlistThe CORS Access-Control-Allow-Origin is your registered origin exactly (scheme + host + port). Wildcards are not used. credentials mode is never allowed on the /me surface.
return= validated before renderThe connect page validates your return= URL against your per-consumer path-prefix allowlist before the consent step renders. A URL not in the allowlist refuses to start — open-redirect is structurally prevented, not filtered.
Consent before importConsent is recorded per source before the import queue entry is written. A consent receipt (rcpt_v1_) is minted for every consent action since July 2026. Receipts are included in the user’s DSR export.

Contract-pinned surface

The full API surface is published as a frozen, catalogue-numbered contract — /contracts/v1 (YAML, comments included). NINSEI pins this file’s commit SHA as the contract it built against.

Adding a consumer is a contract change (a PR touching both the YAML and the mirrored consumers.ts source), not a runtime config change. Response-shape changes and endpoint removals are breaking — coordinated via carrefour before merge. Drift between the YAML and the implementation is asserted by a required CI check on every change.

Pin the version. Breaking changes arrive as a new contract version, coordinated with consumers before merge — never as a silent reshape of v1.

NINSEI: a static SPA, no backend

NINSEI is a static SPA — no backend server at all (served from object storage + a CDN). It integrated the full taste flow browser-side: connect button, code exchange, profile read — all without a server that ever sees the user’s token or library.

Your servers stay out of the PII path the same way. NINSEI is live at ninsei.hosaka.fm and is the first consumer on the contract.

One known consumer duty (from the contract): a script injected via XSS on your origin can read the token from your browser storage. The blast radius is one player’s read-only artist list — taste cannot fix consumer XSS. Keep your origin clean; revoke a suspect token via DELETE /api/v1/me/token.

What you build on it

The resolved_cluster_ids in the profile response are keys into the hosaka map — the same identity coordinate that the whole hosaka fleet shares. Join them against crate’s public dossier API to build your own recommendations, artist pages, or discovery features. (taste’s own rec engine serves the first-party dashboard; its endpoint is not yet on the published consumer contract — ask if you need it.)

The hosaka map (as of July 2026): ≈1.97 million artist clusters from 14 independent source kinds across 448 scene areas. External catalogue IDs (MusicBrainz, Discogs) are annotations, never the identity key — so the newest artists count too.

Getting access

taste is in dub-plate. Each consumer is added by a contract change — your origin and return-URL prefix added to the allowlist in a PR that touches both the YAML and the mirrored source. There is no self-serve key issuance.

To start the conversation, email hello@hosaka.fm with the subject “taste — integration access” and a short description of what you’re building. Include your origin URL — that’s the main thing the contract change needs.

No public rate card yet. Pricing is being finalised, not withheld for effect. Free while in dub-plate.

Ask for integration access →
Contract

The full published surface, frozen and versioned.

View contract v1 →
Privacy

How taste holds the data your users connect through you.

How taste holds data →