API Reference

Documentation

supaserv takes HTML plus inline assets and returns PDF, PNG, JPEG, WebP or MP4. Synchronous rendering is stateless and fetches nothing at render time. Every font and image travels with the request; a bundled ICC profile may be named instead. The video job form adds ephemeral state with a named lifetime.

Authentication

All /v1 endpoints require Authorization: Bearer YOUR_API_KEY. POST requests use Content-Type: application/json. Keys are issued by the operator, one per client; there is no self-service, cookie or query-parameter authentication. A missing, malformed or unknown key returns 401 {"code":"auth"}.

Gateway and worker-direct calls use separate client keys. Worker-direct keys are configured as SUPASERV_KEY_<LABEL>. WORKER_KEY belongs to the gateway and is never handed out. Jobs through the gateway require a named key; a worker-direct key is always named. Use the same host and key to create, poll and fetch a job: gateway and worker-direct identities do not see each other’s jobs.

Access paths

PathBody limitWhen to use
https://www.supaserv.io4 MBDefault for screen PDFs and images; ~25 s before the response starts. Video jobs use this path; synchronous video returns 400 unsupported. No response carries Content-Length.
https://api.supaserv.io32 MBPrint PDFs, larger bodies, long renders and synchronous video. Synchronous artefacts and job results carry Content-Length.

POST /v1/pdf

Returns the PDF bytes. Page size, margins, orientation and bleed come from @page CSS. Unknown top-level fields are rejected.

FieldTypeDefaultDescription
htmlstringRequiredComplete HTML document with head and inline style.
profile"screen" | "print""screen"screen: PDF 1.7. print: PDF/X-4 with an ICC output intent, bleed and crop marks.
assetsarray[]Inline assets; see the shared asset fields below.
crop_marksbooleantrueprint only; ignored on screen.
iccstring—print only. Bundled profile name: iso-coated-v2-300 (ISO Coated v2 300% (ECI)). Never combine with an inline ICC asset.
output_intentstringFrom icc, else "ISO Coated v2 300% (ECI)"print only. /OutputConditionIdentifier; must agree with a named icc. With an inline ICC, the caller must ensure it describes that profile.

print requires exactly one ICC source: a name or one application/vnd.iccprofile asset. Missing, multiple or unparseable inline profiles fail with 500 render_failed. Colour is passed through, not converted: author CMYK CSS and supply CMYK images for print.

curl  -X POST https://www.supaserv.io/v1/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head><style>body{margin:0}</style></head><body>Hello</body></html>"}' \
  -o output.pdf
HTTP/1.1 200 OK
Content-Type: application/pdf
X-Engine: weasyprint

POST /v1/image

Returns one PNG, JPEG or WebP capture. Waits for fonts and layout; if the document defines window.__SCENE_READY = true, capture waits for it for up to 8 s.

FieldTypeDefaultDescription
htmlstringRequiredComplete HTML document.
widthintegerRequiredCSS pixels, 1–8000.
heightintegerFull pageCSS pixels, 1–8000. If omitted, viewport is width × 1080 and capture is full-page.
format"png" | "jpeg" | "webp""png"Output format.
scalenumber1Device pixel ratio, 1–4; output dimensions are CSS dimensions × scale.
transparentbooleanfalsepng and webp only; with jpeg: 400 unsupported.
qualityinteger851–100; jpeg and webp only, ignored for png.
assetsarray[]Shared asset fields below.
curl  -X POST https://www.supaserv.io/v1/image \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head><style>body{margin:0}</style></head><body>Hello</body></html>","width":1080}' \
  -o output.png
HTTP/1.1 200 OK
Content-Type: image/png
X-Engine: chromium
X-Pages: 1

Shared asset fields

FieldTypeDefaultDescription
namestringRequiredExact case-sensitive reference name. Strip leading ./, query and fragment before matching; no path resolution or percent-decoding.
media_typestringRequiredIANA media type, e.g. font/woff2 or image/png.
datastringRequiredStandard RFC 4648 base64 with padding, no data: prefix or newlines; not base64url.

Inline data: URIs need no asset. Assets are checked on arrival; invalid base64, invalid image/font/video bytes or more than 1 GiB of inflated asset data fail with 400 asset_missing. A reference with no matching asset fails. Absolute fetchable URLs, <base> and local() fonts are rejected.

POST /v1/video

In force since SPEC 1.3 (13.10.2026). The scene clock, synchronous worker-direct form and job form with gateway relay are built. clock: "virtual" is not built: it answers 400 unsupported, also when clock is left out, since it is the default. Nothing is rendered with a clock other than the one asked for.

FieldTypeDefaultDescription
htmlstringRequiredComplete HTML document.
width, heightintegerRequiredEven CSS/output dimensions, each 2–1920; width × height ≤ 2,073,600.
duration_msintegerRequired100–30000.
fpsinteger301–30.
clock"virtual" | "scene""virtual"Explicitly send scene; virtual is not built and returns 400 unsupported.
format"mp4""mp4"Only format.
jobbooleanfalsetrue accepts a job; otherwise worker-direct synchronous MP4.
assetsarray[]Same assets and preflight as image.

Frames = ceil(duration_ms × fps / 1000), at most 900. scene waits up to 8 s for window.__SCENE_READY === true, then calls __SCENE_SEEK(Math.round(i * 1000 / fps)) for each frame and captures once the call has finished: it has returned and, if it returned a promise, the promise has resolved (at most 5 s per call, then 500 render_failed; SPEC 1.4). The function must produce the same picture for the same argument. Missing readiness or seek function fails with 500 render_failed. Pause independent animations and timers and drive them from seek.

Writing a scene

  • The length is yours, not the scene's. Only duration_ms, width, height and fps from the request count; nothing the scene declares is read. A 7 s scene requested at 10 s gets 3 s of whatever its seek draws past its end, in a 200. Keep the four numbers in one place and build the request from it.
  • Load before ready. supaserv waits for document.fonts.ready, which covers only the fonts used so far. Load every weight and decode every image a later frame needs before you set __SCENE_READY.
  • No error channel. A scene that fails on its own can only leave the flag unset; the caller waits 8 s and gets a message that does not say why. Check the scene in a browser first.
  • The last frame is before the end. Frame i is at round(i × 1000 / fps) ms and stops at frames − 1: 7000 ms at 30 fps ends with frame 209 at 6967 ms. A final state placed exactly at the end is, as a rule, not in the clip.
  • No state between calls. Calls come in rising order, but the picture for one ms must not depend on earlier calls. Write every animated value on every call.
  • Finished means finished. What the seek starts and does not await has an undefined state in the frame: an image that gets its src in the seek, a requestAnimationFrame callback, a CSS transition on a value the seek writes (remove it). Resolve a returned promise on the event that says the work is done (seeked, decode()), never on a timer.
<img id="product" src="product.png" alt="">
<h1 id="line">Der Becher, der warm hält.</h1>
<script>
  const product = document.getElementById("product");
  const line = document.getElementById("line");
  const span = (ms, a, b) => Math.min(1, Math.max(0, (ms - a) / (b - a)));

  // Every value from ms alone, every value on every call
  window.__SCENE_SEEK = (ms) => {
    const p = span(ms, 400, 1500);
    product.style.transform = "translateY(" + 260 * (1 - p) + "px)";
    product.style.opacity = p;
    line.style.opacity = span(ms, 1400, 2150);
  };
  window.__SCENE_SEEK(0);

  // Ready only once everything a later frame needs is there
  Promise.all([document.fonts.load('700 40px "Brand"'), product.decode()])
    .then(() => { window.__SCENE_READY = true; });
</script>

A background video in a scene

Name the file as an asset (video/mp4) and reference it directly, <video src="bg.mp4" muted>; never play it. supaserv answers its Range requests, which makes it seekable. Four rules, each from a measured failure that was a 200:

  1. Seek to the middle of the frame: video.currentTime = ms / 1000 + 1 / (2 * fps).
  2. Before __SCENE_READY: wait for canplaythrough, then make exactly one seek, to the first frame, and wait for its seeked.
  3. Source and output at the same frame rate.
  4. Return a promise from the seek that resolves on seeked (at once if video.seeking is false right after the assignment).

All-intra sources (-g 1 -bf 0) are not required, they make seeks faster. A seek over 5 s is the 500 above.

Same request, same bytes: per image

With the scene clock, the same request to the same worker image gives the same MP4, byte for byte. A new image may give other bytes for the same request: Chromium and FFmpeg come from the image, and their output depends on the build and the processor architecture, which the version number does not show. If you store hashes, expect them to change on a deploy. Equal bytes prove a render repeats, not that it is right.

Output is H.264, yuv420p, constant frame rate, faststart MP4 with no audio; add sound with POST /v1/mix. No scale, transparency, GIF, WebM, image sequence, muxing, concatenation, transcoding, progress stream or resume. The whole clip is rendered before a synchronous 200; a failed frame fails the whole render.

Synchronous example — worker-direct only

curl  -X POST https://api.supaserv.io/v1/video \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head></head><body><script>window.__SCENE_SEEK = (ms) => {}; window.__SCENE_READY = true;</script></body></html>","width":1080,"height":1920,"duration_ms":10000,"fps":30,"clock":"scene"}' \
  -o output.mp4
HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Length: <whole file size in bytes>
X-Engine: chromium
X-Clock: scene
X-Frames: 300

The gateway rejects this form with 400 unsupported.

Job example

curl  -X POST https://www.supaserv.io/v1/video \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head></head><body><script>window.__SCENE_SEEK = (ms) => {}; window.__SCENE_READY = true;</script></body></html>","width":1080,"height":1920,"duration_ms":10000,"fps":30,"clock":"scene","job":true}'
HTTP/1.1 202 Accepted
Content-Type: application/json

{"job_id":"job_7KpQ2mV9xR4sT6uW8yZaBg","status":"queued","position":3,"eta_min_s":320,"eta_s":380}

The id, positions and estimates in these examples are illustrative SPEC snapshots. Validation, asset checks, preflight and admission run before 202. After acceptance, a disconnect does not cancel the job. The id is job_ plus 22 base64url characters from 16 random bytes; possession alone grants no access.

POST /v1/mix

A finished MP4 and a sound file in, the same picture with that sound out. No browser and nothing rendered: a new jingle is a mix of about a second, not a new render. Both start at 0 and the video's length wins: longer sound is cut, shorter sound is followed by silence to the end. A sound track the video already has is replaced.

FieldTypeDefaultNotes
video.media_typestringRequiredvideo/mp4. H.264, one video track, not fragmented, at most 300 s.
video.datastringRequiredStandard base64.
audio.media_typestringRequiredaudio/mpeg (MP3), audio/aac (ADTS) or audio/mp4 (M4A). AAC means AAC-LC.
audio.datastringRequiredStandard base64.
audio.autoblendbooleanfalsetrue is a linear fade-out over the last 500 ms, the same as fade_out_ms: 500. Both fields together are 400 unsupported.
audio.fade_out_msintegernone0 to 10000, exactly that fade, ending at the clip's last sample. Longer than the clip is 400 unsupported.
curl  -X POST https://api.supaserv.io/v1/mix \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"video":{"media_type":"video/mp4","data":"..."},"audio":{"media_type":"audio/mpeg","data":"...","autoblend":true}}' \
  -o clip-with-sound.mp4

The picture is copied, not encoded again. The sound comes out as AAC-LC, 48 kHz, stereo, 192 kbit/s; mono goes on both channels at the level it came in. HE-AAC is refused where the file declares it (not measured, so not promised). Both files are checked by their bytes, not by the declared type: WAV, FLAC, Ogg, a cut-off MP3 or a clip that is not H.264 answer 400 asset_missing with the field and the format in the message, and no file. There is never a silent video in place of an error. Without a fade field there is no fade. Two mixes run at once; a third is 429 with Retry-After: 5. No job form. X-Clip-Ms, X-Audio-Ms and X-Fade-Out-Ms state what was mixed.

Same bytes per image, not per version. Two identical requests to the same worker image give the same file. The same request gave three different sha256 on three FFmpeg builds (x86 and arm64 of the same package version, and another version); X-Engine-Version names the version, not the build. A deploy may change the bytes; the image build fails on any FFmpeg package other than the measured one, so that change is never silent on our side.

GET /v1/jobs/:id

curl https://www.supaserv.io/v1/jobs/job_7KpQ2mV9xR4sT6uW8yZaBg \
  -H "Authorization: Bearer YOUR_API_KEY"
HTTP/1.1 200 OK
Content-Type: application/json
Retry-After: 10

{"job_id":"job_7KpQ2mV9xR4sT6uW8yZaBg","status":"queued","position":1,"eta_min_s":210,"eta_s":260}
FieldPresent whenMeaning
job_id, statusAlwaysId; queued, rendering, done or failed.
positionQueued or rendering0 for rendering, 1 for next in the FIFO queue across all clients.
eta_min_s, eta_sQueued or renderingEstimated seconds to outcome, lower/upper span; both null if there is no basis for an estimate.
bytes, expires_atDoneArtefact bytes; expiry in RFC 3339 UTC, 15 min after completion.
errorFailed{code, message}: stored code from the closed list and the original failure message.

Poll queued/rendering status at the Retry-After: 10 hint. Estimates are not promises: remaining frames ahead divided by the two slots, plus the job’s own frames, are multiplied by 0.12 s (rounded down to 10 s) and 0.14 s (rounded up to 10 s). Synchronous renders are not counted; other scenes may fall outside the span. Submitted HTML and assets are dropped at the outcome and never returned in job responses.

GET /v1/jobs/:id/result

curl https://www.supaserv.io/v1/jobs/job_7KpQ2mV9xR4sT6uW8yZaBg/result \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o output.mp4
HTTP/1.1 202 Accepted
Content-Type: application/json
Retry-After: 10

{"job_id":"job_7KpQ2mV9xR4sT6uW8yZaBg","status":"rendering","position":0,"eta_min_s":null,"eta_s":null}
StateResponse
queued / rendering202 with the same status JSON and Retry-After: 10.
done200 with MP4 bytes and the video headers below; Content-Length worker-direct only.
failedStored {code, message} and original HTTP status: 500 or 504 for render_failed; 413 for an oversized artefact. Readable until failure retention expires.

The MP4 is fetched once: after the worker writes the full body, the artefact and status are deleted; a later fetch is 404 unsupported. Unknown, expired, fetched and another client’s ids all return 404 {"code":"unsupported","message":"no such job"}.

A started fetch runs past expiry and hides the job while streaming. If the connection drops before completion, the job becomes visible again before its original expiry; at or after expiry it is deleted. A response lost after the worker finished writing requires a new render. A download that stalls without dropping holds its artefact and store reservation past expiry; the result stream has no idle timeout.

Job admission and lifetime

Limit / conditionBehaviour
Render slots2 shared by synchronous video and jobs. One FIFO job queue, no per-client fairness. Waiting jobs get slots first; synchronous requests with waiting jobs are refused.
Queue10 waiting jobs; full: 429 rate_limited, Retry-After: 30, message job queue full; nothing queued.
Retention15 min after done; failed status and error retained 15 min after failure. Queue wait does not consume retention.
Store256 MiB total. Reserve 80 MiB per accepted job until done, then count its real size until fetched or expired; failed jobs count nothing.
Per artefact80 MiB = 83,886,080 bytes. Oversized output fails with too_large; result answers 413, file deleted.
Store fullIf stored + reserved + another 80 MiB exceeds 256 MiB: 429 rate_limited, Retry-After: 60, message job store full; nothing queued, no existing artefact evicted.
Shutdown30 s drain. Waiting jobs discarded immediately; new jobs: 429 rate_limited, Retry-After: 30, message worker shutting down; no job id. Rendering jobs run until exit and are then lost.
RestartQueue, statuses and artefacts disappear; old ids answer 404. A 404 after 202 means resubmit.
Render budget180 s from rendering start, excluding queue wait. Timeout or failure to acquire a browser page: failed with render_failed, stored status 504; not requeued.

GET /health

curl https://www.supaserv.io/health

No authentication. Always answers 200 with {"status":"ok","service":"supaserv"}; status is degraded when the render backend is not configured or does not answer its own health check with a 2xx within 3 s. Not part of the /v1 contract.

Errors

Closed list for /v1. Error shape: {"code":"asset_missing","message":"…"}. Map code; never parse message. A non-JSON 5xx means the request never reached the service.

CodeHTTPTrigger
auth401Missing, malformed or unknown Authorization. Job relays with a missing, empty or malformed authenticated client label fail closed.
unsupported400Malformed JSON; missing/empty html; unknown field; invalid enum or out-of-range number; absolute fetchable URL, local() font or base element. PDF: unknown icc, icc on screen, named plus inline ICC, conflicting output_intent. Image: transparent with jpeg, undecodable reference or assets with colliding encoded names. Video: odd dimensions, pixel/frame excess, unavailable virtual clock (also default), synchronous gateway request, or video/audio elements in virtual-clock preflight. Gateway jobs require a named key.
unsupported404Unknown /v1 path; unknown, expired, fetched or another client’s job id.
asset_missing400Unresolved font or other non-data: reference; invalid base64, invalid image/font/video bytes, or inflated assets over the decode budget.
too_large413Body over the selected path limit; finished job artefact over its per-artefact limit.
rate_limited429Gateway per-client rate limit; worker queue full or page-slot wait exceeded; video slots unavailable; job queue/store full; shutdown. Retry-After values below.
render_failed500Missing/multiple/unparseable print ICC, failed bundled ICC hash, failed PDF/X-4 write, engine crash. Image/video: requested resource missing, failing to load or decode at render time. Video: scene not ready, missing seek, failed frame or encoder; other engine failure.
render_failed504Render time budget exceeded; job browser-page acquisition timeout.

Limits

LimitGatewayWorker-direct
Request body4 MB32 MB
PDF screen duration~25 s before response starts60 s
PDF print duration~25 s before response starts120 s
Image duration~25 s before response starts30 s
Video durationSynchronous form rejected; job result must start within ~25 s and may stream afterwards180 s; jobs count from rendering start
POST /v1/pdf per client30 / minNo per-client limit
POST /v1/image per client60 / minNo per-client limit
POST /v1/mix per client30 / min, shared with POST /v1/pdfNo per-client limit
Mix duration / concurrency~25 s before response starts30 s; 2 at once, a third is 429
POST /v1/video jobs per client5 / minNo per-client limit
GET /v1/jobs/* per client60 / min, shared status/result bucketNo per-client limit
Browser pool / queue—3 pages; queue 20; slot wait up to 20 s or own budget, whichever is shorter
Timeout teardown—Can add up to ~15 s after budget; normally under 1 s
PDF restart wait—Up to ~30 s after an overrun kills the profile renderer; waiting counts against request budget

Worker PDF/image budgets start when preflight passes; queue time counts. PDF requests within a profile run one at a time in arrival order. There is no concurrency guarantee. Video/job-specific admission limits are above. Gateway cuts can discard a synchronous render’s answer while rendering continues.

429 variants

TriggerRetry-After (seconds)
Gateway per-client rate limit60
Worker browser queue full or slot wait exceeded / saturated pool5
Synchronous video: two slots occupied or a job waiting10
Job queue full30
Job store full60
Worker shutting down30

Response headers

HeaderMeaning / scope
Content-Typeapplication/pdf; image/png, image/jpeg or image/webp; video/mp4. Job/status/error JSON: application/json.
Content-LengthWhole artefact byte size, worker-direct only; absent on every gateway response.
X-Engine, X-Engine-VersionRenderer and its version: weasyprint for PDF, chromium for image/video. Version is the one used for that call.
X-PagesPDF page count; image always 1.
X-Duration-MsRender wall time in milliseconds, not clip length.
X-Fonts-EmbeddedPDF embedded fonts; image/video asset-satisfied font families used. Split on comma-space and percent-decode entries; non-printable/non-ASCII bytes, percent and comma are encoded.
X-ICC-SHA256PDF print only: lowercase SHA256 of the embedded named or inline ICC.
X-Clock, X-FramesVideo only: clock and frame count.
Retry-After429 retry delay; queued/rendering status 200 and result 202 poll hint: 10 s.

Retired routes

Pre-v1 gateway /render/* routes and their worker-direct counterparts were retired on 24.09.2026 and removed on 12.10.2026. They now answer 404; there are no retirement stubs. Use /v1/pdf and /v1/image.