> ## 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.

# Device / PIN flow (AUTH V2)

> RFC 8628 for TVs, consoles, watches and CLIs. Show an 8-character code, poll until the user approves.

This is the flow for anything that cannot reasonably open a browser or accept typed input — smart TVs, consoles, set-top boxes, watches, CLIs, media-server plugins.

Your device shows a short code, the user types it on their phone, and your device polls until the approval lands. No `client_secret`, no redirect URI, no browser on the device.

<Tip>
  **The two URLs below can also be discovered rather than hardcoded.** Simkl's [discovery document](/api-reference/auth-v2#discovery) lists them as `device_authorization_endpoint` and `token_endpoint`, and includes `urn:ietf:params:oauth:grant-type:device_code` in its supported grants. Use whichever suits your library — the examples on this page set the URLs directly.
</Tip>

<Note>
  **Any V2 client type can use this flow** — Mobile, desktop & browser apps, TV, devices & command line, or Server apps & services. You do not have to register as a TV or device app to use the device flow.

  Pick **TV, devices & command line** when the device flow is the *only* flow your app will ever use: that type registers no redirect URI, which also means it cannot run the [authorization code flow](/api-reference/oauth2-authorization-code). If you have a web or mobile companion that signs users in through a browser, register **Mobile, desktop & browser apps** and use both flows from the one app.

  Client type is fixed at registration and cannot be changed later, so this is worth a moment's thought.
</Note>

<Warning>
  **An [AUTH V1](/api-reference/pin) app cannot use this flow.** It gets `401 invalid_client` with the message "This client\_id is not enabled for OAuth 2.0". If you see that while holding credentials that work fine elsewhere, you are pointing V1 credentials at a V2 endpoint — register a V2 app rather than debugging the request.
</Warning>

## The flow

<Steps>
  <Step title="Ask for a device code" icon="qrcode">
    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl -X POST https://api.simkl.com/oauth2/device \
      -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 "scope=media:read media:write"
    ```

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "device_code": "...",
      "user_code": "BDWP-HQPK",
      "verification_uri": "https://simkl.com/pin",
      "verification_uri_complete": "https://simkl.com/pin?user_code=BDWP-HQPK",
      "expires_in": 900,
      "interval": 5
    }
    ```

    Omitting `scope` gives you `media:read` only. If your device writes anything — scrobbling, marking watched — ask for `media:write` explicitly.
  </Step>

  <Step title="Show the code to the user" icon="display">
    Display `user_code` exactly as returned, hyphen included, and point the user at `verification_uri`.

    If you can render a QR code, encode `verification_uri_complete` instead — it pre-fills the code, so the user types nothing at all. That is a meaningful difference on a TV.

    Keep `device_code` secret. It is the credential your device polls with, and it should never appear on screen.
  </Step>

  <Step title="Poll for approval" icon="rotate">
    Poll `POST /oauth2/token` every `interval` seconds — 5 by default:

    ```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
    curl -X POST https://api.simkl.com/oauth2/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -H "User-Agent: my-app-name/1.0" \
      --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
      --data-urlencode "client_id=YOUR_CLIENT_ID" \
      --data-urlencode "device_code=YOUR_DEVICE_CODE"
    ```

    Until the user approves, every poll returns an `error` telling you what to do next. See the table below.
  </Step>

  <Step title="Store the tokens" icon="circle-check">
    Once approved you get the standard token response — access token valid 7 days, refresh token valid 180 days. From here the device behaves like any other client.

    **Check the `scope` in that response before you rely on it.** An unrecognised scope string is silently downgraded to read-only rather than rejected, so a typo in step 1 produces a token that works until your first write. See [Scopes](/api-reference/oauth2-scopes).
  </Step>
</Steps>

## Poll responses

```mermaid theme={"theme":{"light":"github-light","dark":"vesper"}}
flowchart TD
    A["Request device code<br/>show user code or QR"] --> W["Wait interval seconds"]
    W --> E{"Your own deadline<br/>reached?"}
    E -->|Yes| X["Stop. Offer to start again"]
    E -->|No| P["POST /oauth2/token"]
    P --> R{"Response"}
    R -->|tokens| S["Store tokens<br/>stop polling"]
    R -->|authorization_pending| W
    R -->|slow_down| D["Add 5s to interval"]
    D --> W
    R -->|expired_token| X
    R -->|invalid_client| F["Stop. Fix registration"]
```

**The deadline branch is not optional.** Because a declining user produces `authorization_pending` forever, that check is the only thing that ends the loop when someone says no. Without it your device polls until the code expires.

Note the two different status codes below. Everything in the first group is a normal part of polling; the second is a configuration problem that polling will never fix.

| Status | `error`                 | What it means                         | What to do                                                          |
| ------ | ----------------------- | ------------------------------------- | ------------------------------------------------------------------- |
| `400`  | `authorization_pending` | Not approved yet.                     | Keep polling at the same interval.                                  |
| `400`  | `slow_down`             | You polled too fast.                  | **Add 5 seconds to your interval and wait.** See the warning below. |
| `400`  | `expired_token`         | The device code is dead.              | Start over from step 1 with a fresh code.                           |
| `401`  | `invalid_client`        | Unknown client ID, or an AUTH V1 app. | Stop polling. Fix your registration — retrying cannot help.         |

The `401` is raised during client authentication, before your `device_code` is even looked at, so it tells you nothing about the code itself.

<Warning>
  **On `slow_down`, you must actually wait.** The poll timer is reset by the attempt itself, including the attempt that was rejected for being too fast. So a client that retries immediately on `slow_down` keeps re-arming the window and can stay locked out until the code expires, polling furiously the whole time.

  Back off first, then poll. From the device's point of view a self-inflicted lockout looks like Simkl being broken, which is why it is worth getting right.
</Warning>

## Two things RFC 8628 leads you to expect that do not happen here

<Warning>
  **There is no deny signal.** If the user clicks "no" on the approval page, nothing is recorded. Your device keeps receiving `authorization_pending`, exactly as if they had walked away from the screen.

  So do not wait for `access_denied` — it will never arrive. **Implement your own timeout**, and treat `expires_in` (900 seconds) as the outer bound. A sensible device gives up earlier than that and offers to start again.
</Warning>

<Note>
  **`authorization_pending` covers two different situations.** It means "not approved yet", and it also means "approved, but another poll is currently claiming it" — which can happen briefly if your device has more than one poll in flight.

  You cannot tell them apart, and you do not need to. The correct response to both is to keep polling.
</Note>

## Code entry is more forgiving than it looks

The `user_code` is 8 characters from a restricted alphabet, displayed as `XXXX-YYYY`. When the user types it, Simkl normalises the input before matching:

* **Case is ignored** — `bdwp-hqpk` works.
* **Hyphens and spaces are optional** — `BDWPHQPK` and `BDWP HQPK` both work.
* **`I` and `L` are read as `1`, and `O` as `0`** — so the classic misreadings succeed rather than failing.

You do not need to validate or normalise the user's typing yourself, and you should not build a stricter input mask than this. The alphabet already excludes the genuinely ambiguous characters.

## Lifetimes

|                      |                                          |
| -------------------- | ---------------------------------------- |
| Device code lifetime | **15 minutes** (`expires_in: 900`)       |
| Poll interval        | 5 seconds (`interval: 5`)                |
| Access token         | 7 days                                   |
| Refresh token        | 180 days, resetting each time you use it |

Fifteen minutes is generous for someone with their phone in hand and tight for someone who wandered off. Show a countdown if you have the screen space, and make starting over easy.

## See also

<CardGroup cols={2}>
  <Card title="POST /oauth2/device" icon="code" href="/api-reference/simkl/oauth2-device">
    Endpoint reference for the initial request.
  </Card>

  <Card title="POST /oauth2/token" icon="key" href="/api-reference/simkl/oauth2-token">
    The polling endpoint, and the other two grants.
  </Card>

  <Card title="Tokens and refresh" icon="rotate" href="/api-reference/oauth2-tokens">
    Keeping a device signed in without re-prompting.
  </Card>

  <Card title="Migrating from V1" icon="arrow-right" href="/guides/migrating-v1-to-v2">
    Moving a device integration across from the older PIN flow.
  </Card>
</CardGroup>
