API reference

The Vitriol web app exposes an HTTP API at /api/v1. Authenticate with an API key (issued from your Profile tab); send it in the Authorization header. The same engine that powers the UI handles every API call — same formats, same Philosopher's Stone mode, same self-compile output.

Replace YOUR_HOST with your deployment URL (e.g. https://app.vitriol.rocks) and YOUR_API_KEY with a key from Profile → API keys. Keys look like vit_<prefix>_<secret> and are shown only once on creation.

Contents

Auth

Sign in (cookie session)

POST /api/v1/auth/signin
curl -X POST https://YOUR_HOST/api/v1/auth/signin \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"identifier": "USERNAME_OR_EMAIL", "password": "PASSWORD"}'

Returns a JWT access token and sets HttpOnly cookies. Most callers should use API keys instead — the cookie path is for browser sessions.

API key auth (the normal path)

curl https://YOUR_HOST/api/v1/me \
  -H "Authorization: Bearer YOUR_API_KEY"

Other auth endpoints public

GET /api/v1/auth/policy
GET /api/v1/auth/sso/providers
POST /api/v1/auth/signup
GET /api/v1/auth/verify?token=…
POST /api/v1/auth/password-reset
POST /api/v1/auth/password-reset/confirm
POST /api/v1/auth/refresh cookie-based
POST /api/v1/auth/logout

SSO round-trip endpoints browser-driven

Used by the sign-in page when the user clicks "Continue with <provider>". Not typically called directly from API clients — the OAuth/OIDC dance needs a real browser for the consent screens.

GET /api/v1/auth/sso/{provider}/start redirects to IdP
GET /api/v1/auth/sso/{provider}/callback IdP returns here

provider is google, github, or any OIDC slug registered under OIDC providers. Two diagnostic query parameters are supported on /start:

Profile / self

GET /api/v1/me
PATCH /api/v1/me
POST /api/v1/me/password
GET /api/v1/me/api-keys
POST /api/v1/me/api-keys
DELETE /api/v1/me/api-keys/{id}
POST /api/v1/me/request-access

Update theme

curl -X PATCH https://YOUR_HOST/api/v1/me \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"theme": "crimson"}'

Valid themes: default, crimson, verdant, cobalt, parchment, obsidian.

Conversions

List supported formats

GET /api/v1/formats
curl https://YOUR_HOST/api/v1/formats \
  -H "Authorization: Bearer YOUR_API_KEY"

Submit a conversion

POST /api/v1/convert multipart/form-data
curl -X POST https://YOUR_HOST/api/v1/convert \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@input.pdf" \
  -F "dst_ext=.png" \
  -F "stone=true" \
  -F "verify_round_trip=false" \
  -F "password=optional-stone-password"
import requests

r = requests.post(
    "https://YOUR_HOST/api/v1/convert",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    files={"file": open("input.pdf", "rb")},
    data={
        "dst_ext": ".png",
        "stone": "true",
        "verify_round_trip": "false",
        # "self_compile_target": "py",  # optional — "py" or "exe"
        # "password": "stone-password",
    },
)
job = r.json()
print(job["id"], job["status"])
const fd = new FormData();
fd.append("file", fileFromInput);
fd.append("dst_ext", ".png");
fd.append("stone", "true");

const r = await fetch("https://YOUR_HOST/api/v1/convert", {
  method: "POST",
  headers: { "Authorization": "Bearer YOUR_API_KEY" },
  body: fd,
});
const job = await r.json();
python -m web.cli.vitriol_cli login \
  --url https://YOUR_HOST \
  --key YOUR_API_KEY

python -m web.cli.vitriol_cli convert input.pdf \
  --to .png \
  --stone \
  --password optional-stone-password

Form fields: file (the upload), dst_ext (with leading dot, e.g. .png), stone (boolean), self_compile_target (py or exe — requires Stone), verify_round_trip, password (Stone password).

Jobs

GET /api/v1/jobs
GET /api/v1/jobs/{id}
DELETE /api/v1/jobs/{id} cancel
GET /api/v1/jobs/{id}/result downloads the file
POST /api/v1/jobs/download-zip bulk download
WS /ws/jobs/{id} live progress

Bulk download as zip

Streams a single zip archive containing every requested job's output file. Job IDs you can't access (others' files, no download_others_files capability) are silently skipped.

curl -X POST https://YOUR_HOST/api/v1/jobs/download-zip \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids": [42, 43, 44]}' \
  -o conversions.zip

Poll for completion + download

# 1. submit (returns job_id)
JOB_ID=$(curl -s -X POST https://YOUR_HOST/api/v1/convert \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@sample.pdf" -F "dst_ext=.png" -F "stone=true" | jq -r .id)

# 2. poll
while true; do
  STATUS=$(curl -s https://YOUR_HOST/api/v1/jobs/$JOB_ID \
    -H "Authorization: Bearer YOUR_API_KEY" | jq -r .status)
  [ "$STATUS" = "done" ] || [ "$STATUS" = "failed" ] && break
  sleep 1
done

# 3. download
curl -O -J https://YOUR_HOST/api/v1/jobs/$JOB_ID/result \
  -H "Authorization: Bearer YOUR_API_KEY"

WebSocket progress

const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
  `${proto}//YOUR_HOST/ws/jobs/${JOB_ID}?token=YOUR_API_KEY`
);
ws.onmessage = e => {
  const ev = JSON.parse(e.data);
  // ev.type = snapshot | progress | done | failed | cancelled | ping
  console.log(ev);
};

Files

Listing of done conversion outputs that are still on disk. Subject to the per-role retention policy under Server settings → Output retention. Files that have been cleaned up don't appear here even if the job row still exists.

GET /api/v1/files your files (or everyone's, if granted)
DELETE /api/v1/files/{job_id}

Regular users see their own outputs only. Admins with the view_others_files capability see every user's files; the response includes an owner_username field plus is_own: true|false per row. Deleting another user's file requires delete_others_files.

curl https://YOUR_HOST/api/v1/files \
  -H "Authorization: Bearer YOUR_API_KEY"

# → [
#   {
#     "id": 42, "user_id": 7, "owner_username": "alice",
#     "src_filename": "report.pdf", "src_ext": ".pdf", "dst_ext": ".png",
#     "dst_filename": "report.png", "bytes_out": 234567,
#     "finished_at": "2026-03-01T12:34:56Z", "created_at": "...",
#     "is_own": true
#   },
#   ...
# ]

User management admin / super-admin

GET /api/v1/users
POST /api/v1/users
GET /api/v1/users/{id}
PATCH /api/v1/users/{id}
DELETE /api/v1/users/{id}
POST /api/v1/users/{id}/suspend
POST /api/v1/users/{id}/unsuspend
POST /api/v1/users/{id}/ban
POST /api/v1/users/{id}/unban
POST /api/v1/users/{id}/approve
POST /api/v1/users/{id}/deny
POST /api/v1/users/{id}/grant-stone
POST /api/v1/users/{id}/revoke-stone
POST /api/v1/users/{id}/grant-self-compile
POST /api/v1/users/{id}/revoke-self-compile
POST /api/v1/users/{id}/reset-credentials

Create a user

curl -X POST https://YOUR_HOST/api/v1/users \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "email": "a@x", "password": "temp-password", "role": "user"}'

Suspend a user (24h / 3d / 7d / 30d)

curl -X POST https://YOUR_HOST/api/v1/users/USER_ID/suspend \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"duration": "7d", "reason": "violated AUP"}'

Custom roles admin / super-admin

Operator-defined role overlays on top of a built-in base role. A custom role with base_role = user grants additional capabilities on top of the user defaults but can never grant capabilities the user role doesn't allow (the base is a ceiling). Created via this API or the in-UI Manage roles dialog at /admin/users.

GET /api/v1/roles
POST /api/v1/roles
PATCH /api/v1/roles/{id}
DELETE /api/v1/roles/{id}

Create a "Beta Tester" role (user base + Stone capability)

curl -X POST https://YOUR_HOST/api/v1/roles \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Beta Tester",
    "base_role": "user",
    "capabilities": ["use_stone"]
  }'

Assign the role to a user by PATCHing /api/v1/users/{id} with {"custom_role_id": <id>}. The user's base role stays unchanged; capability checks consult the custom role's overlay.

Server settings super-admin

Core settings

GET /api/v1/server/settings
PATCH /api/v1/server/settings
POST /api/v1/server/restart
POST /api/v1/server/refresh-certs SSL cert pull

Master enable toggles

Pause an integration without clearing credentials. The matching *_set booleans in the GET response tell you whether the encrypted column has a value.

# Disable password sign-in entirely (super admin can still sign in as recovery escape hatch)
curl -X PATCH https://YOUR_HOST/api/v1/server/settings \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"password_signin_enabled": false}'

# Pause SMTP without clearing credentials
curl -X PATCH https://YOUR_HOST/api/v1/server/settings \
  ... -d '{"smtp_enabled": false}'

# Toggle Google/GitHub SSO buttons on /signin
curl -X PATCH ... -d '{"oauth_google_enabled": true, "oauth_github_enabled": false}'

Toggle self-signup / set default role

curl -X PATCH https://YOUR_HOST/api/v1/server/settings \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allow_signup": true, "signup_default_role": "pending"}'

Test endpoints

POST /api/v1/server/test-email SMTP probe
POST /api/v1/server/test-discord legacy Discord webhook
curl -X POST https://YOUR_HOST/api/v1/server/test-email \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "[email protected]"}'

Returns a structured SMTP error on failure including the exception class — handy for diagnosing 535 (bad credentials) vs 421 (rate-limited) vs timeout (network).

Server secret key

The signing key for JWT access tokens and the encryption key (via PBKDF2 → Fernet) for every secret stored at rest. Lives in /data/.secret_key + mirrored to vitriol.db. The env var SECRET_KEY (or VITRIOL_SECRET_KEY) wins if set.

GET /api/v1/server/secret-key reveals the current key
POST /api/v1/server/secret-key/rotate generates a fresh one

Rotation decrypts every encrypted column with the old key, generates a new key, re-encrypts with it, persists to both file + DB, then triggers a restart. All active sessions are invalidated.

Settings export / import

POST /api/v1/server/export
POST /api/v1/server/import

Bundles your entire server config (limits, SMTP, OAuth, OIDC providers, custom roles, retention, cert-pull, notification channels) into a single JSON envelope. Optional password encryption (PBKDF2 + Fernet).

# Export unencrypted (contains plaintext secrets — treat the file as sensitive)
curl -X POST https://YOUR_HOST/api/v1/server/export \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"password": null}' \
  -o vitriol-settings.json

# Export with password encryption (recommended)
curl -X POST ... -d '{"password": "a-strong-passphrase"}' \
  -o vitriol-settings.json

# Import — server validates the embedded SHA-256 hash before applying
curl -X POST https://YOUR_HOST/api/v1/server/import \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"envelope": FULL_JSON_FROM_EXPORT, "password": "...", "confirm": true}'

OIDC providers super-admin

Multi-row OIDC provider table. Each row gets its own callback URL fragment (/api/v1/auth/sso/{slug}/callback) and its own button on the sign-in page. The Authentik kind additionally supports auto-provisioning approved users into the IdP via its admin API.

GET /api/v1/server/oidc-providers
POST /api/v1/server/oidc-providers
PATCH /api/v1/server/oidc-providers/{id}
DELETE /api/v1/server/oidc-providers/{id}

Add an Authentik provider

curl -X POST https://YOUR_HOST/api/v1/server/oidc-providers \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Authentik",
    "slug": "authentik",
    "issuer": "https://auth.example.com/application/o/vitriol-web/",
    "client_id": "AUTHENTIK_CLIENT_ID",
    "client_secret": "AUTHENTIK_CLIENT_SECRET",
    "scopes": "openid email profile",
    "enabled": true,
    "provision_kind": "authentik",
    "provision_on_approve": true,
    "provision_api_token": "AUTHENTIK_ADMIN_TOKEN"
  }'

With provision_on_approve: true, every time an admin approves a pending Vitriol user, Vitriol POSTs to Authentik's /api/v3/core/users/ to create a matching account. The user lands in Authentik's directory with no password set; they use Authentik's password-recovery flow to set their own.

Toggle a provider on/off without deleting

curl -X PATCH https://YOUR_HOST/api/v1/server/oidc-providers/ID \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'

Notification channels super-admin

Multi-channel fan-out for admin alerts (new pending user, viewer access requests). Each enabled channel receives a copy of every notification. Eight kinds shipped: discord, slack, ntfy, gotify, telegram, generic_webhook, script, bluesky.

GET /api/v1/server/notification-channels
POST /api/v1/server/notification-channels
PATCH /api/v1/server/notification-channels/{id}
DELETE /api/v1/server/notification-channels/{id}
POST /api/v1/server/notification-channels/{id}/test

Add a Discord channel

curl -X POST https://YOUR_HOST/api/v1/server/notification-channels \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "discord",
    "name": "Ops Discord",
    "enabled": true,
    "config": {},
    "secret": "https://discord.com/api/webhooks/.../..."
  }'

Add an ntfy topic with bearer auth

curl -X POST https://YOUR_HOST/api/v1/server/notification-channels \
  ... -d '{
    "kind": "ntfy",
    "name": "Self-hosted ntfy",
    "enabled": true,
    "config": {
      "server_url": "https://ntfy.your-domain.com",
      "topic": "vitriol-alerts",
      "auth_kind": "bearer"
    },
    "secret": "YOUR_NTFY_TOKEN"
  }'

Fire a one-off test

curl -X POST https://YOUR_HOST/api/v1/server/notification-channels/ID/test \
  -H "Authorization: Bearer YOUR_SUPERADMIN_API_KEY"

Sends "Vitriol — test notification." to that channel only. Stamps last_test_at + last_test_ok on the row so the admin UI's per-row pill reflects the outcome. Failure returns the underlying exception class + message (HTTP status from the target, timeout, etc.).

Per-kind config schemas (config + secret):

Errors & rate limits

All errors return JSON: {"detail": "<reason>"}.

Interactive Swagger

For an always-up-to-date schema (parameter types, response shapes, try-it-now buttons), open the FastAPI-generated docs on your deployment:

GET https://YOUR_HOST/docs Swagger UI
GET https://YOUR_HOST/redoc ReDoc