Developers
Use the API
Sign your script or add-on in with the device flow, get a token, and make your first API calls against your own server.
Your tofa server has an HTTP API, and you're welcome to build against it: a Kodi add-on, a dashboard widget, a Python script that marks things watched. The full endpoint reference lives at API reference. This guide covers the part the reference alone doesn't: how your code signs in.
Use the device flow so people sign in on our site without giving your code their password. Your code receives tokens scoped to their access. API calls can change or delete data, so check each endpoint before using it in a script.
01Find your server and its id#
Every server answers one endpoint without authentication. Ask it who it is:
curl http://192.168.1.50:33333/api/v1/auth/status
{
"claimed": true,
"server_id": "8b1f6a2e-4c3d-4f7a-9e0b-2d5c8a913f44",
"connect_url": "https://api.tofa.tv"
}
You'll need both fields: server_id says which server your token should be scoped to, and connect_url is where sign-in happens.
Which address? Use whatever reaches your server today: the LAN IP and port, or a custom access URL if you've set one up. The API is the same on all of them.
02Request a device code#
Ask the connect service for a pairing code. Pass the server_id from step 1 and a name so the approval screen can say what's asking:
curl -X POST https://api.tofa.tv/device/code \
-H "Content-Type: application/json" \
-d '{"server_id": "8b1f6a2e-...", "client_name": "My watched-sync script", "client_type": "script"}'
{
"device_code": "kf93k...40 characters...",
"user_code": "ABCD-EFGH",
"verification_uri": "https://app.tofa.tv/link",
"verification_uri_complete": "https://app.tofa.tv/link?code=ABCDEFGH",
"expires_in": 600,
"interval": 5
}
Show the person the user_code and the link (the response also includes qr_code_svg if a QR code fits your UI better). They sign in at the link and approve. The code is good for ten minutes.
03Poll for the token#
While they approve, poll the token endpoint. This is the standard OAuth device flow, so the poll is a form post, not JSON:
curl -X POST https://api.tofa.tv/device/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=kf93k..."
Until they've approved you'll get authorization_pending; keep polling every interval seconds (poll faster and you'll get slow_down). Once approved:
{
"access_token": "eyJhbGciOi...",
"refresh_token": "d41d8cd98f...",
"token_type": "Bearer",
"expires_in": 2592000
}
Store both tokens. The access token is good for 30 days.
04Call your server#
From here it's plain Bearer auth against your own server:
curl http://192.168.1.50:33333/api/v1/users/me/continue \
-H "Authorization: Bearer eyJhbGciOi..."
Everything in the API reference works this way, with one exception. Media URLs that a player fetches directly (playlists, segments, images) can't carry headers, so they authenticate with a short-lived token in the st query parameter instead. You don't build those URLs yourself: GET /stream/{id}/info negotiates playback and hands back ready-to-use URLs, and GET /auth/image-token covers artwork.
05Refresh before it expires#
When the access token ages out, trade the refresh token for a new pair instead of pairing again:
curl -X POST https://api.tofa.tv/servers/8b1f6a2e-.../device-token/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "d41d8cd98f..."}'
A refresh token rotates once it is at least 24 hours old. A younger one comes back unchanged, so you can see the same value twice. Always store the refresh_token from the response, whichever you get. If a token that has been replaced is used again, the whole session family can be revoked as a safety measure, so persist the newest pair before discarding the old.
Shortcut for server admins: API keys#
Since server 0.9.34, a server admin can skip the device flow for scripts that only talk to their own server. Under Server > Settings > API keys, create a key, give it a name and, if you want, an expiry. Copy it when it appears; it is shown once. Send it exactly like a token:
Authorization: Bearer <key>
A key acts as the admin who created it, so treat it like that admin's password: keep it in a secrets store or an environment variable, never in a repository, and revoke it from the same page the moment it leaks or the script retires. Revocation is immediate. Keys only work on a direct connection to your own server: on your network, through your own domain or tunnel. They are not accepted by our accounts service or by the remote access path it provides, so a script that runs away from home, needs to find servers, or acts for an ordinary member still uses the device flow above.
Right below your keys, Server > Settings > API shows a reference of every endpoint your server exposes, generated from the version you are running, with a filter and a copyable curl example for each call. That is the quickest way to find the exact path and parameters for the version you have.
The whole thing in Python#
A complete sign-in, first call included:
import json, time, urllib.request, urllib.parse
SERVER = "http://192.168.1.50:33333"
def post_json(url, data):
req = urllib.request.Request(url, json.dumps(data).encode(),
{"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req))
# 1. who is this server?
status = json.load(urllib.request.urlopen(f"{SERVER}/api/v1/auth/status"))
connect, server_id = status["connect_url"], status["server_id"]
# 2. request a device code
code = post_json(f"{connect}/device/code",
{"server_id": server_id, "client_name": "example script"})
print(f"Go to {code['verification_uri']} and enter {code['user_code']}")
# 3. poll until approved
while True:
time.sleep(code["interval"])
form = urllib.parse.urlencode({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": code["device_code"],
}).encode()
req = urllib.request.Request(f"{connect}/device/token", form,
{"Content-Type": "application/x-www-form-urlencoded"})
try:
tokens = json.load(urllib.request.urlopen(req))
break
except urllib.error.HTTPError as e:
if json.load(e).get("error") != "authorization_pending":
raise
# 4. call the server
req = urllib.request.Request(f"{SERVER}/api/v1/users/me/continue",
headers={"Authorization": f"Bearer {tokens['access_token']}"})
for item in json.load(urllib.request.urlopen(req)):
print(item["title"], f"{item['progress_percent']}%")
Reach the server from anywhere#
The examples above use a LAN address. When your code runs away from home, or the server sits behind a network you can't open, ask the connect service how to reach it. With any account token (owner or invited user):
curl https://api.tofa.tv/servers/8b1f6a2e-.../connection-info \
-H "Authorization: Bearer eyJhbGciOi..."
{
"connection_type": "direct",
"online": true,
"connect_url": "https://8b1f6a2e....direct.tofa.tv:33333",
"proxy_url": null,
"proxy_available": false,
"candidates": [
{ "type": "lan-direct", "url": "https://192-168-1-50.8b1f6a2e....lan.tofa.tv:33333", "stagger_ms": 0 },
{ "type": "wan-direct", "url": "https://8b1f6a2e....direct.tofa.tv:33333", "stagger_ms": 100 },
{ "type": "relay", "url": "https://api.tofa.tv/servers/8b1f6a2e-.../relay", "stagger_ms": 200, "available": true }
]
}
candidates is the list to use: try them in order (the stagger_ms offsets
are what our own apps use to race them), and settle on the first that answers.
Three types exist today, and unknown types may appear later, so skip entries
you don't recognize rather than failing.
lan-direct: the server on its own network, with a hostname our wildcard certificate covers so TLS validates normally.wan-direct: a port-forwarded or tunneled path from the internet. Custom access URLs the operator configured appear as extrawan-directentries.relay: the same API proxied through us, athttps://api.tofa.tv/servers/{id}/relay. Append the normal API path:.../relay/api/v1/users/me/continue. Two things to know. It forwards the API only; the server's web interface is not there, so the root answers 404, and anything your code scrapes from the server's front page won't be found on this route. And until the server's relay channel is up you'll get 503 with errorserver_relay_not_connected; that clears on its own, so retry rather than giving up.
proxy_url is the older, pre-candidates form of the relay entry: it is
only non-null when the server has no direct path at all. New code should read
candidates and treat proxy_url as legacy.
Match titles against other catalogs#
If your code needs to line a title up with another service, the detail endpoint GET /api/v1/media/{id} carries the title's known external IDs in provider_ids:
"provider_ids": [
{ "media_id": "7d2b...", "provider": "tmdb", "provider_id": "63639" },
{ "media_id": "7d2b...", "provider": "imdb", "provider_id": "tt3230854" },
{ "media_id": "7d2b...", "provider": "tvdb", "provider_id": "280619" }
]
Look entries up by provider, not by position. A title lists only the IDs we know for it, and the list can grow in later releases. List views (GET /api/v1/media) carry tmdb_id only, so fetch the detail for anything else.
For things happening right now, such as who is watching what, don't poll: see Live events over WebSocket.
Sync watch history#
If you're building a sync tool, these calls replace one request per title. They need server 0.13.0 or later, so check for the sync.user_state capability in GET /api/v1/system/info.
Map the library. GET /api/v1/media/files?library_id=<id>&limit=1000 lists every file with its title, season and episode numbers, and external ids. Pass next_after as after for the next page, until it's null.
Read current state. GET /api/v1/users/me/watch-state?completed_only=true pages the same way. Keep changes_cursor from the first page.
Follow changes. Call GET /api/v1/users/me/state-changes?cursor=<cursor> and store next_cursor each time. Call again straight away while has_more is true.
- It covers watched marks and watchlist changes, including un-marks and removals. It doesn't cover position updates.
- Each entry carries the current state, and an entry can repeat, so apply them idempotently.
- When
requires_full_syncis true, read the state again and continue from the newchanges_cursor.
Write in batches. Both calls take up to 500 items:
curl -X POST http://192.168.1.50:33333/api/v1/media/watched/batch \
-H "Authorization: Bearer eyJhbGciOi..." \
-H "Content-Type: application/json" \
-d '{"items": [{"media_file_id": "4c1e...", "watched": true, "watched_at": "2021-03-04T05:06:07Z"}]}'
curl -X POST http://192.168.1.50:33333/api/v1/users/me/watchlist/batch \
-H "Authorization: Bearer eyJhbGciOi..." \
-H "Content-Type: application/json" \
-d '{"items": [{"media_type": "movie", "tmdb_id": 603, "on_watchlist": true, "added_at": "2021-03-04T05:06:07Z"}]}'
A watched batch returns how many rows changed, and repeating one changes nothing, so retries are safe. One unknown file id or a file the profile can't access fails the whole batch, and nothing in it is applied. From server 0.13.1 the error's failed_ids lists every refused id, so you can drop them and resend the rest. One kind of refusal is reported per response (missing, outside the profile's libraries, or above its rating limit), so a resend can meet the next kind. A future date is treated as now.
Reading dates. On watch progress, last_watched_at is the last time something was watched, and it keeps the original date for imported history. completed_at is when it was finished. It holds while the file stays completed, and it clears if the file is un-marked or a restart saves a new position. Don't use updated_at as a watched date. A later watch of a file that stays completed moves last_watched_at without adding a feed entry.
All of these act on the active profile. To sync another profile, send X-Profile-Id, or X-Profile-Token if the profile is locked.
Good citizenship#
- One pairing per install. Pair once, store the tokens, refresh. Don't run the device flow on every start.
- Respect the poll interval in step 3; the endpoint enforces it.
- Feature-detect, don't version-match.
GET /api/v1/system/inforeportsapi_versionand acapabilitieslist. Servers update on their own schedule, so check for the capability you need instead of assuming. - The API is in beta. Additive changes can land in any release. We won't break documented endpoints casually, but pin your expectations to the reference, not to observed behavior.
If you build something, tell us through help and feedback. We'd like to see it.