Skip to main content

Documentation Index

Fetch the complete documentation index at: https://api.simkl.org/llms.txt

Use this file to discover all available pages before exploring further.

PKCE — Proof Key for Code Exchange, RFC 7636 — is the secure way to run OAuth on a public client: any app where you can’t safely embed a client_secret. The browser-based UX is identical to standard OAuth; the only difference is that the client_secret is replaced by a one-time code_verifier + code_challenge pair the client generates locally. Even if an attacker intercepts the redirect with the authorization code, they can’t redeem it without the verifier — and the verifier never leaves your app until the final POST.

Pick this flow if…

Use PKCEUse a different flow
iOS, iPadOS, watchOS appsServer-side web apps that can keep a secret → OAuth 2.0
Android, Wear OS appsTVs, consoles, smart watches, CLI tools → PIN flow
Single-page apps (React, Vue, Svelte, vanilla JS)
Browser extensions (Chrome, Firefox, Edge)
Desktop binaries (Electron, Tauri, native)
If you’d otherwise be embedding client_secret in a public binary, you want PKCE.
Two domains, two roles. OAuth uses two different hosts — easy to mix up, and the most common cause of “404 Not Found” during integration:
EndpointHostWhat it does
GET /oauth/authorizesimkl.comBrowser-facing consent page. The user lands here, signs in, and approves your app.
POST /oauth/tokenapi.simkl.comServer-to-server code exchange. Your backend posts the code here and gets back an access_token.
If your authorize URL points at api.simkl.com you’ll get a 404 — it has to be simkl.com.

At a glance

Three steps, two HTTP calls:
WhereWhat you sendWhat you get
1. Generate locallyclientA random code_verifier (43–128 chars) and its SHA-256 code_challenge
2. Authorize (browser)simkl.com/oauth/authorizeclient_id, code_challenge, code_challenge_method=S256, stateAfter consent: a ?code=…&state=… redirect
3. Exchange (HTTP POST)api.simkl.com/oauth/tokencode, client_id, code_verifierA long-lived access_token
Tokens last until the user revokes from Connected Apps — there’s no refresh-token flow.

Detailed flow

Generate a verifier and challenge

Locally, before any redirect:
// Pseudocode — use your platform's crypto APIs (see recipes below).
code_verifier  = base64url(random(32 bytes))      // 43–128 unreserved chars
code_challenge = base64url(sha256(code_verifier)) // S256 (recommended)
code_verifier requirements (RFC 7636 §4.1):
  • 43–128 characters
  • Only the unreserved URI characters: A-Z, a-z, 0-9, and - . _ ~
  • Cryptographically random — never reuse across flows
Simkl enforces all three at the token-exchange step — a verifier outside the length range or carrying any other character returns 401 secret_error (“PKCE verification failed”). The error message intentionally matches what a wrong-but-well-formed verifier returns, so format probing reveals nothing.code_challenge_method — Simkl supports two values:
MethodHow it works
S256 (default, recommended)code_challenge = base64url(sha256(code_verifier)) — protects the verifier even if the authorize URL leaks
plaincode_challenge = code_verifier — only acceptable on platforms without SHA-256 (essentially never in 2026)
code_challenge_method is case-sensitive. Send exactly S256 or plain — not s256, sha256, or any other variant. Anything outside the two-value enum is rejected immediately at the authorize step:
{ "error": "invalid_request",
  "error_description": "code_challenge_method must be S256 or plain (RFC 7636 §4.3)" }
If you see that error, double-check the casing.
Where to keep the verifier between step 1 and step 3:
PlatformStorage
iOS / AndroidIn-process memory (or Keychain / EncryptedSharedPreferences if the flow may be backgrounded)
SPAsessionStorage — survives the redirect, cleared on tab close
Desktop CLI / loopbackProcess memory while the local HTTP server waits
Browser extensionchrome.storage.session (or the equivalent)

Authorize

Open a browser session to:
https://simkl.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=YOUR_APP_DEEP_LINK    (optional with PKCE — see "Choose a redirect URI" below)
  &code_challenge=YOUR_CODE_CHALLENGE
  &code_challenge_method=S256
  &state=YOUR_RANDOM_CSRF_TOKEN
  &app-name=my-app-name
  &app-version=1.0
Use a platform-secure browser session — not an embedded WebView:
  • iOS / iPadOSASWebAuthenticationSession
  • Android — Chrome Custom Tabs (or the newer Auth Tab on supported Chrome versions)
  • Desktop — the user’s system browser, with a localhost loopback redirect
  • SPA — a normal full-page navigation
  • Browser extensionchrome.identity.launchWebAuthFlow / browser.identity.launchWebAuthFlow
Simkl shows a consent screen. After approval, the user is redirected to your redirect_uri with ?code=AUTHORIZATION_CODE&state=YOUR_RANDOM_CSRF_TOKEN appended.
Always send state for CSRF protection. Generate a random per-flow value (e.g. crypto.randomUUID()), store it alongside the verifier, and verify on the redirect that the echoed value matches. Especially important on public clients — your custom URI scheme could in principle be triggered by another app on the same device, and state is what proves the response is for the flow you initiated.
User denial doesn’t return an error= parameter. If the user clicks “No” on the consent screen, Simkl redirects to / on simkl.com — not back to your redirect_uri with error=access_denied. Treat any flow where the redirect never lands within a sensible timeout (e.g. 5 minutes) as a denial / cancellation and let the user retry.

Exchange the code for a token

Once you receive the authorization code, POST to the token endpoint with the verifier instead of client_secret:
POST https://api.simkl.com/oauth/token
{
  "code":          "AUTHORIZATION_CODE",
  "client_id":     "YOUR_CLIENT_ID",
  "code_verifier": "YOUR_CODE_VERIFIER",
  "redirect_uri":  "YOUR_APP_DEEP_LINK",
  "grant_type":    "authorization_code"
}
Both content-types and both credential locations work. Simkl’s POST /oauth/token accepts:
  • Content-Type: application/x-www-form-urlencoded (the RFC 6749 §3.2 default) or Content-Type: application/json — pick whichever your HTTP client prefers
  • Client credentials in the request body (client_id + client_secret parameters) or in the Authorization: Basic header (RFC 6749 §2.3.1) — both paths are honored
That means off-the-shelf OAuth libraries work out-of-the-box with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
curl -X POST https://api.simkl.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "User-Agent: my-app-name/1.0" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
  --data-urlencode "code=AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
  --data-urlencode "grant_type=authorization_code"
For library-specific examples (Python, Node, Java, Go, PHP), see OAuth client libraries — most are zero-config.
Notice: no client_secret. Simkl re-derives the challenge from the verifier you send and matches it against what you sent in step 2. If they match, you get back an access_token. If they don’t, the request fails — that’s the protection: an attacker who intercepted the redirect doesn’t have the verifier.Successful response (200):
{
  "access_token": "...",
  "token_type":   "bearer",
  "scope":        "public",
  "expires_in":   157680000
}
expires_in is 5 years in seconds — Simkl tokens are long-lived and there’s no refresh-token grant. If a 401 ever arrives before that lifetime elapses, the user revoked your app at Connected Apps settings; start a fresh PKCE flow.PKCE failure (401):
{
  "error":   "secret_error",
  "code":    401,
  "message": "PKCE verification failed"
}
secret_error is also returned for a wrong client_secret on the confidential flow — distinguishable by the message field. Don’t retry the same code after this error — any exchange attempt (success OR failure) consumes the code (RFC 6749 §4.1.2). Restart the flow from /oauth/authorize with a fresh verifier+challenge pair.Treat the access_token as long-lived — store it securely and reuse it on every authenticated request:
Authorization: Bearer YOUR_ACCESS_TOKEN
Where to store it:
PlatformStorage
iOS / iPadOSKeychain
AndroidEncryptedSharedPreferences or the Android Keystore
macOS / Windows / LinuxOS keychain (Keychain Access, Credential Manager, libsecret)
SPAhttpOnly cookie set by your backend — never localStorage
Browser extensionchrome.storage.local (or the equivalent)

Choose a redirect URI

PKCE works with any redirect URI scheme — but each option has trade-offs:
TypeExampleBest forNotes
Custom URI schememyapp://oauthMobile (iOS, Android)Simplest. Risk: another app could in principle register the same scheme — PKCE mitigates this since the attacker can’t redeem the code without your verifier.
Universal Link / App Linkhttps://yourdomain.com/oauthMobile (iOS 9+, Android 6+)Cryptographically bound to your domain via apple-app-site-association / assetlinks.json. Strongest mobile option.
HTTPS callbackhttps://yourdomain.com/callbackSPAStandard web. The page reads ?code= from the URL and POSTs to the token endpoint.
Loopbackhttp://127.0.0.1:8765/callbackDesktop / CLISpin up a local HTTP server on a free port; Simkl redirects there. Use 127.0.0.1, not localhost.
Whichever you pick, the URI you send in step 2 must match a URL registered in your app settings byte-for-byte (scheme, host, port, path, trailing slash, casing). If you can’t host a redirect target at all — TVs, consoles, CLI tools — use the PIN flow instead. That’s the browser-less alternative; the user enters a 5-character code at simkl.com/pin and your app polls for the result.

Platform recipes

Full PKCE flow with ASWebAuthenticationSession and CryptoKit:
import AuthenticationServices
import CryptoKit

func base64url(_ data: Data) -> String {
  data.base64EncodedString()
    .replacingOccurrences(of: "+", with: "-")
    .replacingOccurrences(of: "/", with: "_")
    .replacingOccurrences(of: "=", with: "")
}

// Step 1 — verifier + challenge
let verifier  = base64url(Data((0..<32).map { _ in UInt8.random(in: 0...255) }))
let challenge = base64url(Data(SHA256.hash(data: Data(verifier.utf8))))
let state     = UUID().uuidString

// Step 2 — open ASWebAuthenticationSession
var components = URLComponents(string: "https://simkl.com/oauth/authorize")!
components.queryItems = [
  .init(name: "response_type",         value: "code"),
  .init(name: "client_id",             value: "YOUR_CLIENT_ID"),
  .init(name: "redirect_uri",          value: "myapp://oauth"),
  .init(name: "code_challenge",        value: challenge),
  .init(name: "code_challenge_method", value: "S256"),
  .init(name: "state",                 value: state),
  .init(name: "app-name",              value: "my-app-name"),
  .init(name: "app-version",           value: "1.0"),
]
let session = ASWebAuthenticationSession(
  url: components.url!,
  callbackURLScheme: "myapp"
) { callbackURL, _ in
  guard let callbackURL,
        let qs = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
        qs.queryItems?.first(where: { $0.name == "state" })?.value == state,
        let code = qs.queryItems?.first(where: { $0.name == "code" })?.value
  else { return }

  // Step 3 — exchange the code for a token
  var req = URLRequest(url: URL(string: "https://api.simkl.com/oauth/token")!)
  req.httpMethod = "POST"
  req.setValue("application/json", forHTTPHeaderField: "Content-Type")
  req.setValue("my-app-name/1.0",  forHTTPHeaderField: "User-Agent")
  req.httpBody = try? JSONSerialization.data(withJSONObject: [
    "code":          code,
    "client_id":     "YOUR_CLIENT_ID",
    "code_verifier": verifier,
    "redirect_uri":  "myapp://oauth",
    "grant_type":    "authorization_code",
  ])
  URLSession.shared.dataTask(with: req) { data, _, _ in
    guard let data,
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let token = json["access_token"] as? String
    else { return }
    // Save `token` to Keychain.
  }.resume()
}
session.presentationContextProvider = self
session.start()

Common pitfalls

Most common causes:
  • code_verifier doesn’t match the original code_challenge — you probably regenerated the verifier between step 2 and step 3, or stored/loaded it incorrectly (URL-encoded once but not the other, padded vs unpadded base64). Returns 401 secret_error with "PKCE verification failed" in the message field.
  • Authorization code expired or already used — codes are short-lived AND single-use (RFC 6749 §4.1.2). Any exchange attempt — success OR failure — consumes the code. If a previous attempt failed for any reason (wrong verifier, format error, network glitch after the request reached the server), the code is gone. Restart from /oauth/authorize with a fresh verifier+challenge.
  • redirect_uri not registered — the URI you send in step 3 must be a registered URL for your app (same whitelist as step 2). Simkl doesn’t currently enforce that step 2 and step 3 use the exact same string, but stricter OAuth implementations do — send the same value both times for forward-compat.
  • code_challenge_method was wrong-cased on authorize — the authorize endpoint rejects anything outside {S256, plain} (case-sensitive) with 400 invalid_request. Re-run the flow with S256.
PKCE requires base64url with no padding (RFC 4648 §5):
  • Use - instead of +
  • Use _ instead of /
  • Strip trailing = characters
Many crypto libraries default to standard base64 (with +, /, and =) and you have to opt into the URL-safe variant. If your verifier or challenge contains any of those three characters, the server-side comparison will silently fail and you’ll get PKCE verification failed. The base64url helper at the top of every recipe above does the substitution explicitly.
Your redirect_uri parameter doesn’t match a URL registered for your app in developer settings, or the redirect didn’t make it back to your app (custom-scheme handler not registered, Universal Link not signed, etc.). Register the URI byte-for-byte, then pass the same string in step 2. If your app genuinely has no place to redirect (TV, console, CLI), use the PIN flow instead.
Use ASWebAuthenticationSession (not SFSafariViewController or a WKWebView) — only ASWebAuthenticationSession lets your app receive the deep-link callback when the user returns. Set callbackURLScheme to your registered scheme.
Each authorization flow should use its own pair. If the user has two browser tabs trying to authorize in parallel, store the verifier under a per-flow key (e.g. pkce_verifier_<state> where state is also passed to /authorize and echoed back).
If your verification rejects a redirect because state doesn’t match what you stored, treat it as a CSRF attempt — discard the code and don’t proceed to the exchange. Most often this is a real bug (you forgot to persist state across the redirect, or you’re reading it from the wrong storage), but it can also indicate that another app on the device intercepted your scheme. Restart the flow with a fresh verifier+state pair.
Append these to every request URL — both public catalog calls and authenticated user calls:
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
ParameterRequiredValue
client_idalwaysYour client_id from your developer settings.
app-namealwaysShort, lowercase identifier for your app (e.g. plex-scrobbler, kodi-trakt-bridge).
app-versionalwaysThe current version of your app, e.g. 1.0, 2.4.1.
These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They’re cheap to send — please always include them.

See also

OAuth 2.0 endpoints

GET /oauth/authorize and POST /oauth/token — both used in this PKCE flow, with parameter combinations specific to public clients.

Choose a flow

Big-picture comparison: OAuth 2.0 (confidential) vs OAuth 2.0 + PKCE (public) vs PIN (browser-less).