REST API reference¶
Every endpoint below is served under /api by the FastAPI app returned
from celery_manager.api.app.create_app(). This page documents actual
current behavior, generated by hand from the route files
(src/celery_manager/api/routes/*.py) — several endpoints evolved during
implementation, so treat this as the source of truth over any earlier
design notes.
Auth¶
All auth is cookie-based: POST /api/auth/login sets an httponly,
samesite=lax session cookie named cm_session (signed with
CeleryManagerConfig.secret_key via itsdangerous, valid 7 days, Path
set to the app's mount point — / unless it is mounted under a prefix);
every other endpoint below reads that cookie via the get_current_user
dependency and returns 401 if it's missing, invalid, expired, or names
a user that no longer exists. There are no API keys, bearer tokens, or
scopes — one authenticated user can do everything (see the
security model).
Besides login and logout, only GET /api/settings is
not behind that dependency, and on purpose — the login screen has to
render this deployment's branding before anyone has signed in.
Cross-origin writes are refused. Any POST/PUT/PATCH/DELETE
(login included) that carries an Origin header whose host doesn't match
the request's Host (or the first X-Forwarded-Host, for proxies that
rewrite Host) gets 403 {"detail": "cross-origin request refused"}
before reaching the route. Requests with no Origin — curl, scripts,
server-to-server clients — are unaffected. If you put celery-manager behind a
proxy that rewrites Host, make Host or X-Forwarded-Host carry the public
host:port the browser used (in nginx: proxy_set_header Host $http_host;
and proxy_set_header X-Forwarded-Host $http_host; — not $host, which drops
the port), or the dashboard's own writes, login included, will be refused. If
login returns 403 cross-origin request refused, this is the cause; see
Behind a reverse proxy.
Every response also carries X-Frame-Options: DENY and
Content-Security-Policy: frame-ancestors 'none', so the dashboard can't be
framed by another site.
| Method | Path | Auth | Request body | Response |
|---|---|---|---|---|
POST |
/api/auth/login |
none | {"username": str, "password": str} |
200 {"username": str} on success; 401 {"detail": "invalid credentials"} on bad username/password. Sets the cm_session cookie. |
POST |
/api/auth/logout |
none | — | 200 {"ok": true}. Deletes the cm_session cookie in this browser. There is no server-side session store to invalidate — the cookie itself is the only state — so a copy of the cookie taken before logout stays valid until it expires; see the security model. |
GET |
/api/whoami |
required | — | 200 {"username": str} if authenticated; 401 otherwise. This is what the dashboard SPA calls on load to decide whether to show the login screen. |
Tasks¶
| Method | Path | Auth | Request | Response |
|---|---|---|---|---|
GET |
/api/tasks |
required | Query params: status, task_name, q, date_from, date_to, limit (default 50), offset (default 0) — all optional. See Filtering, search and pagination below for exact semantics. |
200 {"items": [...], "total": int, "limit": int, "offset": int} — an envelope, not a bare array (see the change note below). Each item is {"task_id", "task_name", "status", "started_at", "finished_at", "runtime_seconds", "progress_current", "progress_total", "progress_message", "progress_updated_at"}, newest first (ordered by internal row id descending). total is the number of runs matching the same filters, ignoring limit/offset. limit/offset are echoed back after clamping, so a client can see what it actually got. |
GET |
/api/tasks/{task_id} |
required | — | 200 — {"task_id", "task_name", "status", "args", "kwargs", "exception", "traceback", "log_output", "queue", "worker", "eta", "started_at", "finished_at", "runtime_seconds", "progress_current", "progress_total", "progress_message", "progress_updated_at", "retried_from_id", "retried_by"} (args/kwargs are JSON-encoded strings, not parsed objects). queue/worker come from the events listener and the timings from the decorator, so any of them may be null for a run that hasn't started or wasn't seen by both. eta is currently always null — see the note below. retried_from_id is the id of the run this one was dispatched as a retry of, or null; retried_by is a list of the ids dispatched as retries of this run (an operator can retry the same failed run more than once), [] if there are none. The four progress_* fields are null unless the task calls report_progress(). 404 {"detail": "not found"} if no run with that task_id exists. |
POST |
/api/tasks/start |
required | {"task_name": str, "args": [...] = [], "kwargs": {...} = {}} |
200 {"task_id": str}. Dispatches immediately via celery_app.send_task(...) and writes an AuditLog row (action="start", target_type="task", target_id = the new task id, details={"task_name", "args", "kwargs"}) — no validation that task_name is a registered task; a bad name fails at the broker/worker level, not here. |
POST |
/api/tasks/{task_id}/cancel |
required | Query param terminate (bool, default false) |
200 {"ok": true}. Calls celery_app.control.revoke(task_id, terminate=terminate) and writes an AuditLog row (action="cancel", target_type="task", target_id=task_id, details={"terminate": bool}). terminate=false only prevents a not-yet-started task from starting; terminate=true also kills it if already running. Does not check that task_id exists in celery-manager's own storage. |
POST |
/api/tasks/{task_id}/retry |
required | — | 200 {"new_task_id": str}. Looks up the original run's stored args/kwargs and re-dispatches them as a brand-new task (a new task_id, not a retry of the original in Celery's own sense). Writes an AuditLog row (action="retry", target_type="task", target_id=task_id, details={"new_task_id": ...}). 404 {"detail": "not found"} if no run exists for task_id. A 500 here means something else: the stored args/kwargs could not be decoded (see Retrying a run the listener recorded), which is a real server-side problem and deliberately keeps surfacing as one. |
POST |
/api/tasks/retry-bulk |
required | {"task_ids": [str, ...]} |
200 {"new_task_ids": [str, ...]}. Retries each id via the same logic as the single-retry endpoint (one AuditLog row per task), in list order. Not partial-failure aware: it stops at the first unknown id and returns 404 {"detail": "no task run found for <id>"} for the whole batch — ids earlier in the list have already been dispatched and audited by then, and the response does not tell you which. Check the ids before sending them, or retry one at a time if that matters. |
Changed: GET /api/tasks no longer returns a bare array
This endpoint used to return a JSON array of runs, hard-capped at 50
rows, with no offset — so older history was simply unreachable, and a
client could not tell "50 results" from "the first 50 of 1,284". It now
returns an envelope:
Callers must change await res.json() to (await res.json()).items.
The list items also gained runtime_seconds and the four progress_*
fields. This is a deliberate break, taken before the 0.1.0a1
alpha was released, and its only consumers are the bundled SPA and
this page — see the docstring on list_tasks in
src/celery_manager/api/routes/tasks.py.
Filtering, search and pagination¶
All parameters are optional and combine with AND.
| Param | Type | Default | Meaning |
|---|---|---|---|
status |
str |
— | Exact match on the run's status: queued, started, succeeded, failed, retried, skipped, revoked. An unknown value is not an error — it just matches nothing. |
task_name |
str |
— | Exact match on the registered task name. Not a prefix and not a substring: task_name=send does not match myapp.send_mail. Use q for partial names. |
q |
str |
— | Case-insensitive substring search across four columns — see below. |
date_from |
datetime | — | started_at >= date_from. |
date_to |
datetime | — | started_at <= date_to. |
limit |
int |
50 |
Page size, clamped into 1..500. The ceiling is celery_manager.control.tasks.MAX_PAGE_SIZE. |
offset |
int |
0 |
Rows to skip, floored at 0. |
limit/offset are clamped rather than rejected: ?limit=10000000 is more
often a stale bookmark than an attack, and a 400 would hand the caller a
broken page instead of a capped one. The values in the response envelope are
the clamped ones.
What q actually searches. It is a substring match (SQL ILIKE
'%term%'; on SQLite, a case-insensitive LIKE) OR-ed across four columns:
task_name, task_id, args, and kwargs. Because args/kwargs are
stored as serialized JSON text, this searches the JSON as a string:
q=alicefinds a run whose kwargs are{"user": "alice"}— and also one whose kwargs are{"reviewer": "alice-bot"}, and one whose task name containsalice.- There is no way to express
kwargs.user == "alice". This is a pragmatic text search, not a JSON query. That trade-off is accepted for v1. - LIKE wildcards inside your term are escaped, so
q=100%searches for a literal100%rather than matching every row.
What the date filters mean. Both bounds apply to started_at, and both
are inclusive (>= / <=). Two consequences:
- A run that never started is excluded by either bound. A queued run has
no
started_at, and SQL comparisons againstNULLare never true. This is intended — a task that has not run did not run "within this range" — but it does mean a date filter silently hides your queue. date_tois an instant, not a calendar day.date_to=2026-08-14parses as midnight, so it excludes everything that ran during the 14th. For a whole day, send2026-08-14T23:59:59.999999(or the start of the 15th, if you prefer a half-open range). Truncating to a day server-side would be wrong for callers who legitimately want an hour-long window.
Timezone handling on date filters
Everything in celery-manager's storage is written as UTC, and on the
default SQLite backend SQLAlchemy binds a datetime by its wall-clock
parts, dropping the offset. date_from=2026-08-10T13:00:00+02:00
was therefore compared as 13:00 rather than as the 11:00Z it denotes,
silently shifting the window by the caller's offset.
The route now converts an offset-carrying bound to UTC before it reaches
the query (utc_bound in src/celery_manager/api/serialization.py), so
any offset you send is honoured. A naive datetime is still taken to
mean UTC — that is this codebase's standing convention, and there is no
per-request timezone to interpret it against.
Datetimes in task responses¶
Every datetime the task endpoints emit — eta, started_at,
finished_at, progress_updated_at — carries an explicit +00:00 offset:
They previously serialized as "2026-08-10T12:00:00", with no offset. The
values were always UTC, but SQLite drops tzinfo on a
DateTime(timezone=True) round-trip, so the naive value reached JSON
unmarked — and new Date(...) on an unmarked string parses it as local
time, shifting every timestamp by the viewer's UTC offset. The API now
stamps UTC back on at the boundary (utc in
src/celery_manager/api/serialization.py). null still means null: a run
that never started has no start time, and inventing one would be worse than
omitting it.
The same helper (celery_manager.api.serialization.utc) does the same job
for the audit log's created_at. The schedule routes don't
need it: their target_time values are computed in memory from an aware
"now" rather than read back from the database, so they never lose their
offset in the first place.
eta is always null today
task_runs.eta exists as a column and GET /api/tasks/{task_id} serves
it, but nothing ever writes it. The events listener copies name,
queue, hostname, args, kwargs, runtime, exception and
traceback off each event and does not copy eta
(src/celery_manager/events/listener.py), and the decorator never sets
it either. So the field is reserved rather than usable: the dashboard's
Execution panel shows "No ETA set" for every run, including tasks
dispatched with a real countdown/eta. Don't build against it until
a writer exists.
The same operations from Python¶
The routes are thin wrappers over celery_manager.control, which takes an
explicit SQLAlchemy session and is usable from a shell, a management command,
or your own code:
from celery_manager import control
with Session() as session:
runs = control.list_task_runs(session, status="failed", q="alice", limit=100)
total = control.count_task_runs(session, status="failed", q="alice")
retries = control.list_retries_of(session, "some-task-id")
branding = control.get_settings(session)
control.update_settings(session, {"brand_accent": "#00ffcc"}, "admin")
list_task_runs and count_task_runs take exactly the same filter keywords
and share one filter builder, so a total can never turn out to be a count of
some other query than the page it labels. MAX_PAGE_SIZE is not applied
here — the clamp lives at the HTTP boundary, and a Python caller is trusted
to ask for more. list_retries_of returns the retry runs oldest-first (the
API's retried_by list is these ids). update_settings validates, audits,
and raises ValueError on bad input — which is what the route turns into a
422. retry_task raises control.TaskRunNotFound (a ValueError
subclass, so existing except ValueError callers keep working) for an
unknown task id — which is what the route turns into a 404 — and a plain
ValueError if the stored args can't be decoded. It also accepts explicit
args/kwargs, which is the only way to retry a run whose stored arguments
are undecodable.
Retrying a run the listener recorded¶
POST .../retry re-dispatches the run's stored args/kwargs, and those
are written in two different formats by two different writers. The decorator
writes JSON (json.dumps); the events listener writes str(event["args"]),
and Celery's event payload is already a Python repr — "(5,)",
"{'b': 2}". So the retry path tries JSON first and
ast.literal_eval (never eval) second.
When neither works, or the decoded value is the wrong type, the retry is
refused with an error naming the field rather than dispatched with
guessed arguments — re-running somebody's task with silently dropped or
reinterpreted arguments is far worse than an error. This is a real case, not
a theoretical one: Celery truncates long values in its event reprs, so a task
recorded only by the listener and called with large arguments can be
genuinely undecodable. The fix is to pass args/kwargs explicitly to
control.retry_task(); the HTTP endpoint has no way to supply them today.
Note also that the decorator's own writes are lossy for values stdlib JSON
cannot express (a datetime kwarg, which Celery's own serializer handles
fine): those leaves are stored as their repr inside otherwise-valid JSON,
so they stay searchable by q= but do not round-trip back to the original
object on retry.
Schedules¶
| Method | Path | Auth | Request | Response |
|---|---|---|---|---|
GET |
/api/schedules |
required | — | 200 — list of {"name": str, "task_name": str, "schedule_spec": str, "enabled": bool}, ordered by name. A pure read of the periodic_schedules table, which create_app() populates once at startup from celery_app.conf.beat_schedule (sync_periodic_schedules). schedule_spec is the str() of the Celery schedule object — human-readable, not machine-parseable ("<freq: 2.00 hours>", "<crontab: 0 7 * * * (m/h/dM/MY/d)>"). enabled is written as True for every synced entry and there is no endpoint to change it, so today it is always true. |
GET |
/api/schedules/{name}/occurrences |
required | — | 200 — list of {"target_time": datetime, "action": "skip"\|"cancel"\|null, "consumed": bool}, one entry per of the next 10 computed occurrences of the periodic schedule named name (from celery_app.conf.beat_schedule), in chronological order. action/consumed reflect any matching unconsumed ScheduleOverride, or null/false if none. Returns [] if name isn't a known schedule (rather than a 404). |
POST |
/api/schedules/{name}/occurrences/pause |
required | {"target_time": datetime, "action": "skip"\|"cancel"} (any other action value is a 422) |
200 {"id": int, "action": str, "target_time": datetime}. Records (or updates) an override for the occurrence closest to target_time. |
Occurrence-matching nuances worth knowing¶
GET .../occurrences recomputes the schedule's next 10 run times relative
to "now" on every call rather than reading them from storage — so a
target_time returned a minute ago will not be byte-identical to the
"same" occurrence's target_time in a fresh call. To keep a paused
occurrence showing as paused despite that drift, matching between computed
occurrences and stored ScheduleOverride rows is done by proximity —
within eta_tolerance_seconds (from CeleryManagerConfig, default 60) —
not exact equality, using the closest override within the window rather
than the first one found.
This required two real fix rounds during implementation (see
_find_within_tolerance and list_upcoming_with_overrides in
src/celery_manager/control/schedules.py):
- Tolerance-window matching instead of exact equality — the initial
version matched
target_timeby exact equality, which meant a paused occurrence stopped showing as paused after even a few seconds, since the recomputed timestamp had drifted from the one originally sent toPOST .../pause. - Claim-based matching to prevent double-pause display — on schedules
whose interval is shorter than
2 * eta_tolerance_seconds, a single override could legitimately fall within the tolerance window of two adjacent occurrences at once.list_upcoming_with_overridesnow matches occurrences against the override pool one at a time in chronological order, removing ("claiming") each matched override from the pool before matching the next occurrence — so the same override is never reported against two rows.
POST .../pause has a matching idempotency behavior on the write side:
calling it again with a target_time within eta_tolerance_seconds of an
existing unconsumed override for the same schedule updates that row's
action and target_time in place (most recent request wins) instead of
inserting a second row — this is what keeps the read side from ever having
two overlapping, ambiguous rows for what a user would consider the same
occurrence (e.g. re-deciding the action after a page reload).
action accepts only "skip" and "cancel". Anything else — including
"reschedule" — is rejected with a 422. The ScheduleOverride table's
action column and its reschedule_to column still permit a reschedule
value at the schema level, but no reschedule-to-a-new-time behavior is
implemented anywhere: the decorator acts on skip/cancel only (see
the decorator page). The endpoint used to accept and store
"reschedule", which then displayed in the dashboard as a paused occurrence
while doing nothing at all — rejecting it at the boundary is the honest
behavior until the feature actually exists.
Settings (branding)¶
Deployment-wide dashboard branding, stored server-side in the app_settings
table so one operator sets it and the whole team sees the same dashboard.
Exactly three keys, and the set is closed.
| Method | Path | Auth | Request | Response |
|---|---|---|---|---|
GET |
/api/settings |
none | — | 200 {"brand_name": str, "brand_accent": str, "brand_logo_url": str} — always all three keys, stored values overlaid on the defaults, so no client needs a fallback of its own. updated_by/updated_at are deliberately not returned. |
PUT |
/api/settings |
required | A JSON object with any subset of the three keys. Unknown keys are a 422 (pydantic extra="forbid"). |
200 — the full effective settings after the update. 422 {"detail": "<human-readable reason>"} if any value fails validation. |
PUT is a partial update: send only the key you are changing. A key that is
absent from the body is left alone; a key sent as null is an explicit value
and is rejected (it is not a string). Every changed key writes one audit
row; keys submitted unchanged are skipped entirely, so the dashboard's
settings form — which posts all three fields whether or not you edited them
— does not bury real changes under no-ops. The whole payload is validated
before anything is written, so a body with a good brand_name and a hostile
brand_logo_url is rejected whole rather than half-applied.
Why GET /api/settings is unauthenticated¶
Deliberately, and it is the reason the key set is closed. The login
screen has to render this deployment's branding, and the login screen is by
definition pre-auth; putting this behind get_current_user would mean the
dashboard shows stock branding until the moment you sign in, which defeats
the feature on the one screen an operator's users see most often.
That is safe only because the payload is non-sensitive by construction — a
product name, a hex colour, and an image URL, and nothing else, because
celery_manager.control.settings.DEFAULT_SETTINGS is a closed allowlist.
Adding a key to that dict is a security decision: anything in it is
world-readable to anyone who can reach the dashboard. updated_by is
omitted from the response for the same reason — it is a real username, and
handing an anonymous visitor one turns password guessing from two unknowns
into one.
Validation rules¶
An operator who gets a 422 can read the exact reason off the detail
string, but here is the whole ruleset.
| Key | Accepted | Rejected (422) |
|---|---|---|
brand_name |
Any single-line string, trimmed, 1–64 characters. Empty or whitespace-only is allowed and means "reset to the default" — it is stored as the literal celery-manager. |
Non-strings; anything longer than 64 characters after trimming; control characters or line breaks. |
brand_accent |
"" (use the built-in accent), or a hex colour — #rgb or #rrggbb, either case. |
Everything else: named colours (red), rgb(...), var(--x), 8-digit #rrggbbaa. |
brand_logo_url |
"" (use the built-in mark); an http:// or https:// URL up to 2048 characters; a same-origin path starting with a single /; or a base64 data: URI of a raster image (png, jpeg, jpg, gif, webp, avif) up to 262,144 characters (MAX_DATA_URI_LENGTH; the error message calls them bytes). |
Any other scheme, including javascript: in any casing; protocol-relative //host/logo.png; a bare relative path like logo.png; data:image/svg+xml; control characters; oversize values. |
The restrictive-looking rules are the point of the feature, not oversights:
brand_accentis an anchored hex-digit allowlist because the value is injected into a CSS custom property (--accent: <value>) in the browser. A permissive check would acceptred; background: url(https://evil/x), which is stored CSS injection — and with it, exfiltration through background-image requests. An allowlist of hex digits has no escape hatch.brand_logo_urlis an allowlist of schemes, never a denylist, because the value lands in an<img src>. A denylist loses to the next encoding the URL parser accepts and the blocklist doesn't (JaVaScRiPt:,%6aavascript:,vbscript:, whatever ships next). With an allowlist, being wrong about a new scheme is a logo that fails to load rather than stored XSS against every visitor — including, becauseGEThere is pre-auth, everyone who merely loads the login page.- SVG data URIs are refused on purpose. An SVG is a document that can
carry
<script>and event handlers. Browsers don't execute it through<img src>, but the same stored string is one copy-paste away from a CSSurl(), an<object>, or a direct navigation, where they do.
Values are stored raw, not HTML-escaped: escaping belongs at the point of
rendering, and escaping on write would double-escape the ampersand in a
legitimate name like Ops & Data.
Accepted v1 limitation: any authenticated user can rebrand
There is no role system in this app — get_current_user yields a
username and nothing else — so "admin only" is not expressible today.
The blast radius is cosmetic and every change is attributed in the
audit log, which is how you find out who turned the nav
pink.
Audit log¶
| Method | Path | Auth | Request | Response |
|---|---|---|---|---|
GET |
/api/audit |
required | Query param limit (int, default 50, clamped into 1..500 — the same MAX_PAGE_SIZE ceiling the task list uses) |
200 — list of {"username", "action", "target_type", "target_id", "details", "created_at"}, newest first (ordered by internal row id descending). details is a JSON-encoded string or null. created_at carries an explicit +00:00 offset, like the task endpoints' datetimes. |
There is no offset here, so limit is the only control: this endpoint
shows the newest N entries and there is no way to page further back through
the audit log over HTTP. Query the audit_log table directly if you need to.
Why limit is clamped at both ends
The floor matters more than it looks. SQLite reads LIMIT -1 as no
limit, so ?limit=-1 used to hand back the entire audit log in one
response. Clamping rather than rejecting matches the task list's
reasoning: a stale bookmark deserves a capped page, not a 400.
Audit rows are written server-side whenever a control action is taken on a
user's behalf, via celery_manager.control.audit.record. There is no
endpoint to write an audit entry directly; entries are always a side-effect
of another action. Every control action is covered:
action |
target_type |
target_id |
Written by |
|---|---|---|---|
start |
task |
the newly dispatched task id | POST /api/tasks/start |
cancel |
task |
the task id being revoked | POST /api/tasks/{task_id}/cancel |
retry |
task |
the original task id | POST /api/tasks/{task_id}/retry and /api/tasks/retry-bulk (one row per task) |
pause_occurrence |
schedule |
the schedule name | POST /api/schedules/{name}/occurrences/pause |
update |
setting |
the setting key (brand_name, brand_accent, brand_logo_url) |
PUT /api/settings — one row per changed key, details={"old", "new"} (each truncated to 200 characters, so a data-URI logo doesn't turn the audit log into an image store) |
A retry records only retry, never an additional start, even though it
dispatches through the same start_task call underneath.
Live updates (SSE)¶
| Method | Path | Auth | Response |
|---|---|---|---|
GET |
/api/stream |
required | text/event-stream. Each event is data: {"task_id": str, "type": str}\n\n (default message event name, no custom event: field), one line per Celery task-lifecycle event forwarded from the events listener (task-sent, task-received, task-started, task-succeeded, task-failed, task-retried, task-revoked) plus any other event types the listener chooses to publish. Connection stays open indefinitely; the client (dashboard) reconnects with the browser's native EventSource retry behavior. |
Like every other endpoint on this page, it returns 401 without a valid
session cookie. (Development builds before 0.1.0a1 had no auth on it at all.) The browser's
EventSource sends the cookie when opened with withCredentials: true,
which is what the dashboard does.
The dashboard's task list subscribes to this stream and refetches
/api/tasks on every event rather than applying the event to its local
state directly — see Dashboard.
Known limitation: SSE does not currently work across processes¶
EventBus (celery_manager.api.sse) is an in-memory fan-out living
inside the API process. The only thing that publishes to it is
EventListener, which the documented deployment runs as a separate OS
process (celery-manager events). A separate process cannot reach another
process's in-memory queues, so in a standard deployment /api/stream
connects successfully, stays open — and never delivers an event.
Because of that, the dashboard's task list also polls /api/tasks every 5
seconds, which is what actually keeps it current today. Updates are
therefore near-live (a few seconds) rather than push-instant. Nothing is
silently broken from a user's point of view, but don't build on /api/stream
as a reliable delivery mechanism yet.
The fix — backing the bus with Redis pub/sub, or running the listener
in-process as a background task of the API app — is a tracked backlog item
(see plan.md, "Cross-process SSE delivery"). It is also what would make SSE
work across more than one API worker behind a load balancer, which has the
same root cause.