---
title: Unattended tools
description: Get a permanent API key through the device-code flow, for config-file tools and scheduled jobs like Kometa or Nuvio.
section: Guides
order: 5
---

# Unattended tools

Some integrations never have a person watching them run: a metadata manager reading a config file, a cron job, a scheduled task on a home server. There's no window to pop up a sign-in screen and nothing to run a 30-day refresh loop in the background. For these, run the device-code flow once with `credential: "key"`, get back a permanent API key, and paste it into the config file for good.

Building something interactive instead, an app someone opens and leaves running? Use the session path in [Authentication](/dev/authentication) instead. [Which credential should your app use?](/dev/authentication#which-credential-should-your-app-use) has the full breakdown.

## 1. Request a device code for a key, not a session

Same call as every device-code flow, with one extra field: `"credential": "key"`.

:::tabs
```bash
curl -X POST "https://flicklist.tv/api/auth/device/code" \
  -H "Content-Type: application/json" \
  -d '{"client_id": "your_client_id", "credential": "key"}'
```
```python
import requests

client_id = "your_client_id"

code = requests.post(
    "https://flicklist.tv/api/auth/device/code",
    json={"client_id": client_id, "credential": "key"},
).json()
print(f"Enter {code['user_code']} at {code['verification_uri']}")
```
:::

The response is the same shape as the session path:

```json
{
  "user_code": "A1B2C3D4",
  "device_code": "b2f1c9e4-...",
  "verification_uri": "https://flicklist.tv/link",
  "expires_in": 900,
  "interval": 5
}
```

## 2. Show the person the code

Print `user_code` and `verification_uri` wherever the person setting this up will see them: stdout, a setup wizard, a log line. They open the link, sign in if needed, and enter the code. This is a one-time step. Nothing about later requests depends on that browser tab staying open.

## 3. Poll until the key arrives

Same poll loop as the session path, waiting `interval` seconds between calls. Because this device code was requested with `credential: "key"`, the moment the person approves, the response carries a permanent key instead of a session token:

:::tabs
```bash
DEVICE_CODE="the device_code from step 1"
INTERVAL=5 # the "interval" from step 1

while true; do
  RES=$(curl -s -X POST "https://flicklist.tv/api/auth/device/token" \
    -H "Content-Type: application/json" \
    -d "{\"device_code\": \"$DEVICE_CODE\"}")
  KEY=$(echo "$RES" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
  [ -n "$KEY" ] && break
  sleep "$INTERVAL"
done
echo "Got a key starting with: ${KEY:0:12}"
```
```python
import time

key = None
while key is None:
    time.sleep(code["interval"])
    body = requests.post(
        "https://flicklist.tv/api/auth/device/token",
        json={"device_code": code["device_code"]},
    ).json()
    if "access_token" in body:
        key = body["access_token"]
    elif body.get("error") != "authorization_pending":
        raise RuntimeError(body.get("error"))
print(f"Got a key starting with: {key[:12]}")
```
:::

The successful response looks like this:

```json
{
  "access_token": "fs_live_...",
  "token": "fs_live_...",
  "user": { "...": "..." },
  "expires_at": null,
  "credential": "key"
}
```

`expires_at` is always `null` on this path. The key doesn't expire on its own.

## 4. Store it and use it forever

Write `access_token` into the config file, environment variable, or wherever this tool keeps its credentials, and send it as a bearer token on every request from here on:

:::tabs
```bash
curl -H "Authorization: Bearer $KEY" "https://flicklist.tv/api/v3/sync/watched/shows"
```
```python
shows = requests.get(
    "https://flicklist.tv/api/v3/sync/watched/shows",
    headers={"Authorization": f"Bearer {key}"},
).json()
```
:::

> [!REQUIRED]
> There is no refresh step for this key, ever, and nothing to write to handle its expiry, because it doesn't have one. The only lifecycle event is a `401`: either the person revoked the key from their Developer page, or they deleted your app entirely, which revokes every key it minted along with it. Either way the fix is the same: run steps 1 through 3 again. Don't build retry or refresh logic around this key; there's nothing to refresh.

Running this flow again for the same person and the same app, a reinstall, a new machine, a re-run setup wizard, rotates that same key in place rather than minting a second one. Their Developer page still shows one entry for your app, now with a different secret. If your tool re-runs setup on its own, overwrite the stored key with the new value and the old one stops working immediately.

## Running on a schedule without wasting your rate budget

A job that wakes up on a cron schedule and re-fetches full collections every run burns through the [1,000 requests/hour ceiling](/dev/rate-limits) fast, mostly on data that hasn't changed since the last run. Poll [`GET /v3/sync/last_activities`](/dev/sync/up-next) first and compare its per-domain timestamps to what you cached last time; only re-fetch the collections that actually moved. It costs a fraction of a full sync and is the intended way to run unattended. [Server-side and unattended clients](/dev/rate-limits#server-side-and-unattended-clients) covers the rest of what running headless expects from a client, mainly a descriptive `User-Agent`.
