OAuth 2.0
Let Listener users connect your app with “Sign in with Listener”, using the authorization code flow with PKCE.
8 min read
Use OAuth when your product acts for other people’s Listener workspaces. Each user signs in with their own account, sees exactly what your app is asking for, and can revoke it at any time. You receive a short-lived access token scoped to their workspace, never their password or an API key.
Listener runs an OAuth 2.1 authorization server at https://api.listener.com. It follows the parts of the spec that matter for security: authorization code only, PKCE required for every client, refresh-token rotation, and exact redirect URI matching.
When to use OAuth#
| Situation | Use |
|---|---|
| A script or backend that reads your own workspace | An API key |
| A SaaS product many Listener customers connect to | OAuth, confidential client |
| A browser, desktop, mobile or CLI app | OAuth, public client (PKCE, no secret) |
| An AI assistant connecting through MCP | OAuth via discovery, handled by the client |
Endpoints#
| Endpoint | Purpose |
|---|---|
GET /oauth/authorize | Start the flow in the user’s browser. |
POST /oauth/token | Exchange a code, or refresh a token. |
POST /oauth/revoke | Revoke an access or refresh token (RFC 7009). |
POST /oauth/introspect | Check a token’s state; confidential clients, own tokens only (RFC 7662). |
GET /oauth/userinfo | The user and workspace behind an access token. |
POST /oauth/register | Dynamic client registration (RFC 7591). |
GET /.well-known/oauth-authorization-server | Server metadata (RFC 8414). Also at /.well-known/openid-configuration. |
Register your app#
Create an OAuth app in Settings → Developers → OAuth apps in either app. You provide:
- Name, logo and homepage: shown on the consent screen. Add privacy policy and terms URLs if you have them.
- Redirect URIs: exact string matches, no fragments. Use
https://URIs; for local development, loopback URIs onlocalhost,127.0.0.1or[::1]are allowed, and for those any port matches (RFC 8252), so a CLI can pick a free port. - Client type: confidential if you can keep a secret on a server, public for browser, mobile, desktop and CLI apps.
You get a client id (lcid_…) and, for confidential clients, a client secret (lcsk_…). The secret is shown once; rotating it later keeps the previous secret valid for 24 hours. A workspace can have up to 25 OAuth apps. Deleting an app revokes every grant and token issued to it.
Authorization code with PKCE#
Create a code verifier and challenge
For every sign-in, generate a random
code_verifierand derive thecode_challengefrom it with SHA-256. OnlyS256is accepted, and PKCE is required even for confidential clients. Also generate a randomstateto protect against CSRF. Store both in the user’s session.import crypto from 'node:crypto'; const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const state = crypto.randomBytes(16).toString('base64url'); // Save codeVerifier and state in the user's session.Send the user to Listener
Redirect the browser to the authorization endpoint:
Authorization URLGET https://api.listener.com/oauth/authorize ?response_type=code &client_id=lcid_… &redirect_uri=https%3A%2F%2Fexample.com%2Fcallback &scope=analytics%3Aread%20shows%3Aread%20offline_access &state=… &code_challenge=… &code_challenge_method=S256Parameter response_typeAlways code.client_idYour client id, or a client ID metadata document URL. redirect_uriOne of your registered redirect URIs, exactly. scopeSpace-separated scopes. Add offline_accessto receive a refresh token. If omitted, it defaults toworkspace:read workspace:write offline_access, so always send it.stateYour random value (up to 2048 characters); it comes back unchanged. code_challengeThe S256 challenge from step 1. Required. code_challenge_methodS256. Required.resourceOptional (RFC 8707): https://api.listener.com,https://api.listener.com/v1orhttps://api.listener.com/mcp. Anything else fails withinvalid_target.promptOptional. consentshows the consent screen even if the user approved before.noneis not supported and returnsinteraction_required.login_hint/accountOptional. account=networkoraccount=creatorskips the product chooser.The user picks Listener (for podcasters and creators) or Listener Networks (for networks, agencies and brands), which is remembered for next time, signs in if needed, and sees a consent screen in that app naming your app, the workspace being shared and each scope you asked for.
Handle the callback
Listener redirects back to your
redirect_uriwithcode,stateandiss(the issuer,https://api.listener.com, per RFC 9207). Check thatstatematches the one you stored and thatissis what you expect. If the user declined, you geterror=access_deniedinstead. The code is single-use and expires after 60 seconds; presenting it twice revokes the tokens it produced.Exchange the code for tokens
curl https://api.listener.com/oauth/token \ -u "lcid_…:lcsk_…" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://example.com/callback \ -d code_verifier="$CODE_VERIFIER"Confidential clients authenticate with HTTP Basic (
client_secret_basic) or by sendingclient_idandclient_secretin the form body (client_secret_post). Public clients send onlyclient_id.Token response{ "access_token": "lat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "lrt_…", "scope": "analytics:read shows:read offline_access", "workspace": { "id": "nW7aQ2kL…", "name": "Northwind Audio", "kind": "network" } }refresh_tokenis present only whenoffline_accesswas granted. Store tokens per workspace: the same user can connect more than one.Call the API
Use the access token exactly like an API key:
curlcurl https://api.listener.com/v1/analytics/reach?time_range=30d \ -H "Authorization: Bearer lat_…"
Scopes#
Ask for the least you need; users are more likely to approve a short list. The consent screen shows each scope’s title and description, and users can see what they granted later under Authorized apps.
| You want to | Request |
|---|---|
| Read reach and rankings | analytics:read shows:read |
| Sync campaigns both ways | campaigns:read campaigns:write |
| Build a reporting product | analytics:read shows:read reports:read |
| Everything, read-only | workspace:read (shown as “Full read access”) |
| Keep working after the user leaves | add offline_access |
The full catalog is in Scopes.
Refreshing tokens#
Access tokens last one hour. Exchange a refresh token for a new pair before or after expiry:
curl https://api.listener.com/oauth/token \
-u "lcid_…:lcsk_…" \
-d grant_type=refresh_token \
-d refresh_token=lrt_…Refresh tokens rotate: every refresh returns a new refresh token and invalidates the one you sent. Always store the newest. If an old, already-rotated refresh token is ever used again, Listener treats it as a leak and revokes the whole token family, so the user has to connect again. Refresh tokens are valid for 60 days from their last use, up to one year in total.
If several workers share one connection, refresh behind a lock. Two workers refreshing with the same token at once means the second one presents a rotated token and revokes the family.
Revoking tokens#
Revoke a token when a user disconnects your app. The endpoint always answers 200, even for unknown tokens.
curl https://api.listener.com/oauth/revoke \
-u "lcid_…:lcsk_…" \
-d token=lrt_…Users can also revoke your app themselves under Settings → Developers → Authorized apps, which kills every token of that grant. Revocation reaches the API within 30 seconds; after that, calls return 401 credential_revoked. Treat that as “the user disconnected” and stop retrying.
Introspection and userinfo#
Confidential clients can ask about their own tokens with POST /oauth/introspect (token=…). The response follows RFC 7662: active, and for active tokens fields such as scope, client_id and exp.
GET /oauth/userinfo with an access token returns the signed-in user and the workspace they shared. Use it to show “Connected to Northwind Audio” in your settings page, or as the identity step of “Sign in with Listener”.
Discovery and dynamic registration#
Clients that discover servers at runtime, such as AI assistants connecting to MCP, do not need to be pre-registered.
- Metadata.
GET /.well-known/oauth-authorization-serverlists every endpoint and supported value. Resource metadata (RFC 9728) is at/.well-known/oauth-protected-resource,/.well-known/oauth-protected-resource/v1and/.well-known/oauth-protected-resource/mcp. The MCP endpoint’s401points to the last one in itsWWW-Authenticateheader. Bothapi.listener.comandmcp.listener.comserve the same documents, each naming its own host as the issuer. - Dynamic client registration.
POST /oauth/registerwithclient_nameandredirect_uriscreates a public client (no secret,token_endpoint_auth_method: none) unless you ask forclient_secret_basic. Redirect URIs must behttps://or loopback. Dynamically registered clients are marked unverified.
Client ID metadata documents#
Instead of registering, a client can use an https:// URL as its client_id. Listener fetches that URL (5-second timeout, 64 KB limit, no redirects, cached for an hour), checks that the document’s client_id equals the URL and that your redirect_uri is listed in its redirect_uris, and treats you as a public client (token_endpoint_auth_method must be none).
{
"client_id": "https://example.com/oauth/client.json",
"client_name": "Example Analytics",
"redirect_uris": ["https://example.com/callback"],
"logo_uri": "https://example.com/logo.png"
}“Sign in with Listener” guidelines#
- Say where they are going. Label the button “Sign in with Listener” or “Connect Listener”, with the Listener mark. Do not imply Listener endorses your product.
- Ask late, ask small. Start the flow when the user reaches a feature that needs Listener data, and request only the scopes that feature uses. Ask for more later with a new authorization request.
- Show the result. After connecting, show which workspace is connected (from
workspace.namein the token response) and a Disconnect action that calls/oauth/revoke. - Handle “no”.
access_deniedis a normal outcome. Return the user to where they were with a short explanation, not an error page.
Errors#
Errors in the authorization request are sent back to your redirect_uri as error and error_description query parameters, with your state:
error | Meaning |
|---|---|
access_denied | The user declined. |
invalid_request | A required parameter is missing or malformed, often code_challenge. |
invalid_scope | A requested scope does not exist or is not allowed for this client. |
invalid_target | resource is not one of the Listener resources. |
interaction_required | You sent prompt=none; Listener always shows its consent screen. |
unauthorized_client | The client may not use this flow. |
unsupported_response_type | response_type was not code. |
server_error | Something failed on our side. Try again. |
If the client_id or redirect_uri itself is wrong, Listener cannot safely redirect, so it shows an error page instead.
The token, revoke and introspect endpoints are rate limited per IP address (60 requests per minute together; dynamic registration 20 per hour), answering 429 with Retry-After.
The token endpoint returns standard OAuth JSON errors ({"error": "invalid_grant", "error_description": "…"}). invalid_grant means the code expired or was used, the code_verifier does not match, or the refresh token was rotated or revoked. invalid_client means client authentication failed.