Skip to content
Listener.comDevelopers

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#

SituationUse
A script or backend that reads your own workspaceAn API key
A SaaS product many Listener customers connect toOAuth, confidential client
A browser, desktop, mobile or CLI appOAuth, public client (PKCE, no secret)
An AI assistant connecting through MCPOAuth via discovery, handled by the client

Endpoints#

EndpointPurpose
GET /oauth/authorizeStart the flow in the user’s browser.
POST /oauth/tokenExchange a code, or refresh a token.
POST /oauth/revokeRevoke an access or refresh token (RFC 7009).
POST /oauth/introspectCheck a token’s state; confidential clients, own tokens only (RFC 7662).
GET /oauth/userinfoThe user and workspace behind an access token.
POST /oauth/registerDynamic client registration (RFC 7591).
GET /.well-known/oauth-authorization-serverServer 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 on localhost, 127.0.0.1 or [::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#

  1. Create a code verifier and challenge

    For every sign-in, generate a random code_verifier and derive the code_challenge from it with SHA-256. Only S256 is accepted, and PKCE is required even for confidential clients. Also generate a random state to 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.
  2. Send the user to Listener

    Redirect the browser to the authorization endpoint:

    Authorization URL
    GET 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=S256
    Parameter
    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_access to receive a refresh token. If omitted, it defaults to workspace: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/v1 or https://api.listener.com/mcp. Anything else fails with invalid_target.
    promptOptional. consent shows the consent screen even if the user approved before. none is not supported and returns interaction_required.
    login_hint / accountOptional. account=network or account=creator skips 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.

  3. Handle the callback

    Listener redirects back to your redirect_uri with code, state and iss (the issuer, https://api.listener.com, per RFC 9207). Check that state matches the one you stored and that iss is what you expect. If the user declined, you get error=access_denied instead. The code is single-use and expires after 60 seconds; presenting it twice revokes the tokens it produced.

  4. 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 sending client_id and client_secret in the form body (client_secret_post). Public clients send only client_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_token is present only when offline_access was granted. Store tokens per workspace: the same user can connect more than one.

  5. Call the API

    Use the access token exactly like an API key:

    curl
    curl 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 toRequest
Read reach and rankingsanalytics:read shows:read
Sync campaigns both wayscampaigns:read campaigns:write
Build a reporting productanalytics:read shows:read reports:read
Everything, read-onlyworkspace:read (shown as “Full read access”)
Keep working after the user leavesadd 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
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.

Tip:

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
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-server lists every endpoint and supported value. Resource metadata (RFC 9728) is at /.well-known/oauth-protected-resource, /.well-known/oauth-protected-resource/v1 and /.well-known/oauth-protected-resource/mcp. The MCP endpoint’s 401 points to the last one in its WWW-Authenticate header. Both api.listener.com and mcp.listener.com serve the same documents, each naming its own host as the issuer.
  • Dynamic client registration. POST /oauth/register with client_name and redirect_uris creates a public client (no secret, token_endpoint_auth_method: none) unless you ask for client_secret_basic. Redirect URIs must be https:// 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).

https://example.com/oauth/client.json
{
  "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.name in the token response) and a Disconnect action that calls /oauth/revoke.
  • Handle “no”. access_denied is 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:

errorMeaning
access_deniedThe user declined.
invalid_requestA required parameter is missing or malformed, often code_challenge.
invalid_scopeA requested scope does not exist or is not allowed for this client.
invalid_targetresource is not one of the Listener resources.
interaction_requiredYou sent prompt=none; Listener always shows its consent screen.
unauthorized_clientThe client may not use this flow.
unsupported_response_typeresponse_type was not code.
server_errorSomething 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.