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.
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.
curl https://YOUR_HOST/api/v1/me \
-H "Authorization: Bearer YOUR_API_KEY"
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.
provider is google, github, or any OIDC slug
registered under OIDC providers. Two diagnostic query parameters are
supported on /start:
?test=1 — round-trips through the IdP without creating a user account; renders a success page showing the IdP-returned sub/email/name so operators can verify config.?debug=1 — returns a plain-text page showing the exact redirect URI the server would send (useful for diagnosing redirect_uri_mismatch without round-tripping).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.
curl https://YOUR_HOST/api/v1/formats \
-H "Authorization: Bearer YOUR_API_KEY"
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).
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
# 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"
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);
};
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.
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
# },
# ...
# ]
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"}'
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"}'
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.
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.
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}'
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"}'
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).
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.
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.
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}'
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.
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.
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}'
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.
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/.../..."
}'
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"
}'
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):
config={}, secret = webhook URLconfig={server_url, topic, auth_kind: 'none'|'bearer'|'basic'}, secret = token or "user:pass" (or empty for public)config={server_url}, secret = app tokenconfig={chat_id}, secret = bot tokenconfig={url, method, headers_json, body_template}, secret = optional bearer token (auto-added as Authorization: Bearer ...)config={script} (bash body — $VITRIOL_MESSAGE in env), no secretconfig={handle, server}, secret = app passwordAll errors return JSON: {"detail": "<reason>"}.
401 — missing or invalid token / API key403 — your role lacks the capability (e.g. Stone not granted)409 — username/email collision413 — file exceeds max_file_size_bytes429 — per-user rate limit or daily conversion quota exceededFor an always-up-to-date schema (parameter types, response shapes, try-it-now buttons), open the FastAPI-generated docs on your deployment: