Skip to content

The HTTP API (The Front Desk With Wristbands)

The Front Desk

A big festival has a front desk. Visitors never wander backstage to move the lights themselves: they come to the desk, show their wristband, and ask. A green band lets you look at the timetable (a viewer). A gold band also lets you ask the crew to do things, like start a show early or stop one (an operator). The steward does not keep a copy of your band, only its fingerprint, so a stolen guest list opens no gates.

PyScheduler's HTTP API is that front desk. The pysched command, your scripts and the web UI all talk to it in JSON, and it talks to the engine for them.

Wristbands (Bearer Tokens)

Callers send a header:

Authorization: Bearer <token>

The api_tokens_file lists each band's name, the SHA-256 fingerprint of its token (64 lowercase hex digits), and its role:

- {name: ci, token_sha256: 3f5a...e1, role: operator}
- {name: dashboard, token_sha256: 9b1c...07, role: viewer}
  • GET requests need a viewer or an operator; anything else needs an operator.
  • A missing or unknown token gets 401 {"error": "unauthorised"} (with WWW-Authenticate: Bearer); a viewer trying an action gets 403 {"error": "forbidden"}.
  • GET /api/health needs no token at all, so a load balancer can ask.
  • The token's name is recorded as the actor of every action, so the logbook says who pressed the button.
  • A malformed tokens file stops the server at start-up with a message naming the file and line.
  • POST /api/reload and SIGHUP read the tokens file again too, so a token you delete from it stops working straight away, with no restart. If the new tokens file is broken, the whole reload fails (the old config and the old tokens stay) and the problem is reported. A reload cannot switch between development mode and tokens: that needs a restart.
  • The pysched command reads its token from PYSCHED_TOKEN. Prefer that over --token: anything on a command line shows up in ps for every user on the machine.

Every POST is JSON. In every mode, a POST must carry Content-Type: application/json (a ; charset=utf-8 is fine), even when it has no body; anything else gets 415 {"error": "use Content-Type: application/json"}. A web page on another site can make your browser send a plain form or a text/plain post, but not a JSON one without asking the server first, so this stops a page you happen to visit from pressing buttons. The pysched command always sends the header.

Development mode. With no tokens file and no users file, everyone is dev with the admin role. The server then refuses to listen anywhere but a loopback address such as 127.0.0.1, so other machines cannot reach it. That is not the same as private: every program on this machine can, including your web browser while it shows some other site. So in development mode a request whose Host header is not localhost, 127.x.x.x or [::1] (with an optional port) gets 403 {"error": "forbidden host"}. That stops a page that points its own domain name at 127.0.0.1 (a trick called DNS rebinding) from reading or steering the scheduler. Use tokens on any machine you share.

Three Ways In (Auth Modes)

Which wristbands the desk accepts depends on two settings in the scheduler: block. There is one access layer behind both the JSON API and the web pages, so a rule is written once.

api_tokens_file users_file Mode
no no Development mode. Loopback Host only. Every request, page or API, is dev with the admin role: no login, no CSRF token. The JSON-only POST rule above is the CSRF defence, and the pages post through fetch as JSON.
yes no Tokens only. The API works as before; the web pages answer 403 "the web UI needs a users_file".
either yes Normal mode. People log in at /login and get a session; scripts still send bearer tokens (if there is a tokens file).

The users_file is a list of {name, password_hash, role}. The role is viewer, operator or admin. In the API an admin can do what an operator can: POST /api/reload and POST /api/alerts/<id>/replay are operator actions, so operator tokens and pysched reload keep working. What admin adds is the web UI's admin pages (config versions, sources, diffs and the dead-letter tray with its reload and replay buttons). Make the password_hash line with pysched hash-password, which asks twice, does not show the password, and prints password_hash: pbkdf2_sha256$...:

from pyweb.security import hash_password, verify_password

stored = hash_password("correct horse battery staple")
print(f"- {{name: ada, password_hash: {stored}, role: operator}}")
assert verify_password("correct horse battery staple", stored)
assert not verify_password("wrong", stored)

A reload (POST /api/reload or SIGHUP) reads the users file again, as it does the tokens file; a bad users file fails the whole reload and the old users stay.

Sessions. A successful login sets the pysched_session cookie, signed with a secret of 48 random bytes. The secret is made on first start and kept in <state_dir>/session.key (mode 0600), so sessions survive a restart. A session lasts 8 hours. The cookie is signed, not stored, so signing out clears it from your browser but does not revoke a copy someone else took; removing the user from the users_file and reloading does. That reload (like one that removes a token) also ends every open /events stream and log tail, so a removed user or a revoked token loses even a stream it already had open; everyone else reconnects and misses nothing.

The cookie is marked Secure unless the scheduler listens on a loopback address; scheduler: secure_cookies: true|false overrides that. The scheduler speaks only plain HTTP and a browser never returns a Secure cookie over plain HTTP, so off loopback either serve the UI through an HTTPS reverse proxy or, on a trusted network, set secure_cookies: false. When the cookie is Secure off loopback, start-up logs one warning saying so.

CSRF. A cookie is sent by the browser on every request to the site, including ones another site's page tricked it into making. So a request that uses a cookie and changes something must also carry the session's CSRF token, in the X-CSRF-Token header (what the web UI sends on its JSON posts) or in a csrf_token form field (what HTML forms send). Every page embeds the token in a <meta name="csrf-token"> tag for exactly this. A missing or wrong token is refused with 403.

Bearer wins. If a request has an Authorization: Bearer header the cookie is never looked at, and the CSRF check is skipped (a header an attacker's page cannot make the browser add). Roles for the API are the same either way: a viewer is limited to GET/HEAD (403 otherwise), /api/* without a user is 401, and a POST must be JSON (415).

What You Can Ask

Route What it does
GET /api/health {"status": "ok", "timezone": "Europe/London", "today": "2026-10-19"} (the scheduler's time zone and today's date there), or 503 {"status": "failed"} when the engine has failed stop
GET /api/jobs every job: owner, tags, criticality, schedules as text, next firing
GET /api/jobs/{name} the same plus its definition, its next 5 firings and its 20 latest runs
GET /api/runs?run_date=&job=&state=&limit= runs, newest first (limit 1-1000, default 100)
GET /api/runs/{id} one run with its attempts, events and alerts (each with its deliveries)
GET /api/runs/{id}/log?stream=stdout&attempt=&tail=200 the last lines of an attempt's log (tail up to 5000)
GET /api/forecast?from=&to= the predicted runs of a window (default the next 24 hours, at most 31 days)
GET /api/alerts?kind=&job=&since=&limit= alerts, newest first (since is a date, limit 1-1000, default 100), each with its deliveries: notifier, status (pending, sent or dead), attempts, last_error, updated_at; a bad kind is a 400
GET /api/alerts/dead?limit= the dead deliveries (ones that gave up), each with its alert
POST /api/jobs/{name}/trigger a manual run; body {"run_date": "YYYY-MM-DD"} optional
POST /api/jobs/{name}/backfill body {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"}
POST /api/runs/{id}/{action} rerun (body {"downstream": true} optional), hold, release, kill, force-start, skip, mark-success, mark-failed
POST /api/alerts/{alert_id}/replay send an alert's dead deliveries again; body {"notifier": "name"} optional. 404 for an unknown alert, 409 no dead delivery for <id> when nothing is dead
POST /api/reload reload the config files; 400 with problems if they are broken (the old config stays)
GET /api/config/validate check the files on disk without loading them

The alert routes answer 503 {"error": "alerts are not running"} if the app was built without a dispatcher. Like job summaries, every alert message and delivery error has the config's ${NAME} values put back as ${NAME}, so a webhook address or password never shows.

Run ids are one path segment: /api/runs/extract-trades.2026-10-17.1/rerun.

The Shapes

  • Instants are ISO 8601 in UTC with a Z: "2026-10-19T18:30:00Z".
  • Dates are "2026-10-19"; enums are their values ("succeeded").
  • Durations are seconds, in fields ending _seconds.
  • A run in GET /api/runs/{id} has duration_seconds from its latest attempt (null while that attempt runs); run lists leave it out.
  • A job's definition shows its environment variable names only ("***" for each value), because values may be secrets.
  • Any value the config got from ${NAME} is put back as ${NAME} in every string of a job summary or definition (command, cwd, description, alerts, ...), longest values first. Values shorter than 4 characters are left alone (hiding them would mangle ordinary text), so do not keep secrets that short.
  • A job's alerts settings (webhooks, addresses) are shown to operators only; viewers see "***".
  • Paths (log files, working directories, config file names) are shown to viewers too: the API is an operator tool for a trusted network, and knowing where a log lives is part of reading it.
  • When the engine has failed stop, actions answer 503 "scheduler failed; see the scheduler log"; the cause is only in the log.
  • Errors are always {"error": "..."} with 400, 404, 409 or 503. An unexpected bug answers 500 {"error": "internal error"} and the details go to the scheduler's log, never to the caller.

Only the Safe Truth

The API shows the run book's published view: states already safely written to the store, so it never shows a state that a crash could take back. Runs older than the in-memory window (memory_days plus the dependencies' reach) are read from the store. Every store read and log file read happens on a helper thread, so one slow disk never holds up the event loop.

Try It

Make a token and its fingerprint for the tokens file:

import hashlib
import secrets

token = secrets.token_urlsafe(32)  # give this to the caller
print(token)
print(hashlib.sha256(token.encode()).hexdigest())  # put this in the tokens file

Then drive an API in memory with pyweb's TestClient (no network needed):

from datetime import UTC, datetime
from pathlib import Path
import tempfile

import pynet.loop
from pyweb.testing import TestClient

from pyscheduler.api import build_app, config_actions
from pyscheduler.config import load_config
from pyscheduler.engine.clock import FakeClock
from pyscheduler.engine.core import Engine
from pyscheduler.store import Store
from pyscheduler.testing import FakeLauncher

home = Path(tempfile.mkdtemp())
path = home / "pysched.yaml"
path.write_text(f"""
scheduler:
  state_dir: {home / "state"}
  log_dir: {home / "logs"}
jobs:
  bake:
    command: ./bake.sh
    schedule: manual
""")

config = load_config(path)
clock = FakeClock(datetime(2026, 10, 19, tzinfo=UTC))
engine = Engine(
    config, Store.open(home / "state", sync=False), clock=clock, launcher=FakeLauncher(clock)
)
reload, validate = config_actions(engine, path)
client = TestClient(build_app(engine, tokens=None, reload=reload, validate=validate))
local = {"Host": "127.0.0.1:8470"}  # development mode answers loopback hosts only


async def visit() -> None:
    await engine.start()
    made = await client.post("/api/jobs/bake/trigger", json={}, headers=local)  # no token
    run_id = made.json()["run"]["run_id"]
    print(run_id)  # bake.2026-10-19.1
    detail = (await client.get(f"/api/runs/{run_id}", headers=local)).json()
    print([event["actor"] for event in detail["events"]])  # ['scheduler', 'dev', 'scheduler']
    await engine.stop()


pynet.loop.run(visit())