---
collection: spotify
protocol: rest
vault: keychain
env:
  prod: https://api.spotify.com/v1
vars:
  token_url: https://accounts.spotify.com/api/token
  limit: 1
---

# Spotify — client-credentials dogfood
> Proves Missive's runner path: **pre-request → Capture → authed call**, against a real API, with
> client-credentials only (no user login). The token endpoint (`accounts.spotify.com`) is a
> different host from the API (`api.spotify.com`), so it uses `{{var:token_url}}` while the API call
> uses `{{env}}`.
>
> **Live fire (controller, network on):**
> 1. Create a free app at developer.spotify.com → get its Client ID + Client Secret. The dashboard
>    requires a redirect URI even though client-credentials never redirects — use an explicit
>    loopback like `http://127.0.0.1:8080` (Spotify rejects `localhost`).
> 2. Store both halves once, **in your own terminal** (STDIN-fed; never via a command line that
>    lands in an agent transcript):
>    `printf %s '<client-id>' | scripts/vault.sh set SPOTIFY_CLIENT_ID`
>    `printf %s '<client-secret>' | scripts/vault.sh set SPOTIFY_CLIENT_SECRET`
> 3. Fire: `scripts/missive-run.sh spotify.api.md 'GET /search — authed catalog search' --env prod`
>    (new dev-mode apps get 403 on `/browse/new-releases` — see its Notebook caveat; `/search` is the live proof)
>
> The runner fires the token endpoint first (dependency inferred from `{{capture:ACCESS_TOKEN}}`),
> captures `.access_token`, then fires the authed call with it. The client secret and the captured
> token are redacted out of the verdict. The `(cache .expires_in)` hint reuses the token across runs
> until it nears expiry (a ~10%/60s margin); rotating the client secret invalidates the cache.

## POST /api/token — get access token
**Request**
```http
POST {{var:token_url}}
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id={{vault:SPOTIFY_CLIENT_ID}}&client_secret={{vault:SPOTIFY_CLIENT_SECRET}}
```
**Capture**
- ACCESS_TOKEN <- .access_token (cache .expires_in)
**Checks**
- `status` == 200
- `.token_type` == "Bearer"

**Notebook**
- 📋 state 2026-07-01T20:19:17Z — fired live (runner-driven, as a capture dependency): 200, `.access_token` captured and cached per `(cache .expires_in)` (0600 file in the 0700 cache dir); subsequent runs reused the cached token without re-firing this endpoint.
- ⚠️ caveat 2026-07-01T20:23:51Z — `SPOTIFY_CLIENT_SECRET` is time-boxed (~180 days): rotated 2026-07-01 → **expires ≈ 2026-12-28**. Rotate in the dashboard, re-`set` in a local terminal; the token cache self-invalidates on rotation (fingerprint-keyed).

## GET /browse/new-releases — authed call
**Request**
```http
GET {{env}}/browse/new-releases?limit={{var:limit}}
Authorization: Bearer {{capture:ACCESS_TOKEN}}
```
**Checks**
- `status` == 200
- `.albums.items | length` > 0

**Notebook**
- ⚠️ caveat 2026-07-01T20:19:17Z — 403 `{"error":{"status":403,"message":"Forbidden"}}` with a **valid** client-credentials token (auth proven by `/search` → 200 on the same cached token). Matches Spotify's 2024/25 restrictions on browse/editorial endpoints for newly created dev-mode apps. Keep for older/extended-quota apps; use `/search` as the authed-call proof.

## GET /search — authed catalog search
**Request**
```http
GET {{env}}/search?q=daft+punk&type=album&limit={{var:limit}}
Authorization: Bearer {{capture:ACCESS_TOKEN}}
```
**Checks**
- `status` == 200
- `.albums.items | length` > 0

**Notebook**
- 📋 state 2026-07-01T20:19:17Z — GET /search → 200 live; `.albums.items[0]` = Daft Punk "Discovery" (album search, limit 1).
- ✅ outcome 2026-07-01T20:19:17Z — **the runner's marquee path proven against a real API**: OAuth2 client-credentials producer fired → `.access_token` captured → cached → this call reused the cached token (producer NOT re-fired) → Checks green → client secret and token both absent from the verdict.
