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
| Path | Body limit | When to use |
|---|---|---|
| https://www.supaserv.io | 4 MB | Default 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.io | 32 MB | Print 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.
| Field | Type | Default | Description |
|---|---|---|---|
| html | string | Required | Complete 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. |
| assets | array | [] | Inline assets; see the shared asset fields below. |
| crop_marks | boolean | true | print only; ignored on screen. |
| icc | string | — | print only. Bundled profile name: iso-coated-v2-300 (ISO Coated v2 300% (ECI)). Never combine with an inline ICC asset. |
| output_intent | string | From 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.
| Field | Type | Default | Description |
|---|---|---|---|
| html | string | Required | Complete HTML document. |
| width | integer | Required | CSS pixels, 1–8000. |
| height | integer | Full page | CSS pixels, 1–8000. If omitted, viewport is width × 1080 and capture is full-page. |
| format | "png" | "jpeg" | "webp" | "png" | Output format. |
| scale | number | 1 | Device pixel ratio, 1–4; output dimensions are CSS dimensions × scale. |
| transparent | boolean | false | png and webp only; with jpeg: 400 unsupported. |
| quality | integer | 85 | 1–100; jpeg and webp only, ignored for png. |
| assets | array | [] | 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
| Field | Type | Default | Description |
|---|---|---|---|
| name | string | Required | Exact case-sensitive reference name. Strip leading ./, query and fragment before matching; no path resolution or percent-decoding. |
| media_type | string | Required | IANA media type, e.g. font/woff2 or image/png. |
| data | string | Required | Standard 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.
| Field | Type | Default | Description |
|---|---|---|---|
| html | string | Required | Complete HTML document. |
| width, height | integer | Required | Even CSS/output dimensions, each 2–1920; width × height ≤ 2,073,600. |
| duration_ms | integer | Required | 100–30000. |
| fps | integer | 30 | 1–30. |
| clock | "virtual" | "scene" | "virtual" | Explicitly send scene; virtual is not built and returns 400 unsupported. |
| format | "mp4" | "mp4" | Only format. |
| job | boolean | false | true accepts a job; otherwise worker-direct synchronous MP4. |
| assets | array | [] | 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,heightandfpsfrom 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
iis atround(i × 1000 / fps)ms and stops atframes − 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
msmust 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
srcin the seek, arequestAnimationFramecallback, a CSStransitionon 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:
- Seek to the middle of the frame:
video.currentTime = ms / 1000 + 1 / (2 * fps). - Before
__SCENE_READY: wait forcanplaythrough, then make exactly one seek, to the first frame, and wait for itsseeked. - Source and output at the same frame rate.
- Return a promise from the seek that resolves on
seeked(at once ifvideo.seekingis 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.
| Field | Type | Default | Notes |
|---|---|---|---|
| video.media_type | string | Required | video/mp4. H.264, one video track, not fragmented, at most 300 s. |
| video.data | string | Required | Standard base64. |
| audio.media_type | string | Required | audio/mpeg (MP3), audio/aac (ADTS) or audio/mp4 (M4A). AAC means AAC-LC. |
| audio.data | string | Required | Standard base64. |
| audio.autoblend | boolean | false | true 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_ms | integer | none | 0 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}
| Field | Present when | Meaning |
|---|---|---|
| job_id, status | Always | Id; queued, rendering, done or failed. |
| position | Queued or rendering | 0 for rendering, 1 for next in the FIFO queue across all clients. |
| eta_min_s, eta_s | Queued or rendering | Estimated seconds to outcome, lower/upper span; both null if there is no basis for an estimate. |
| bytes, expires_at | Done | Artefact bytes; expiry in RFC 3339 UTC, 15 min after completion. |
| error | Failed | {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}
| State | Response |
|---|---|
| queued / rendering | 202 with the same status JSON and Retry-After: 10. |
| done | 200 with MP4 bytes and the video headers below; Content-Length worker-direct only. |
| failed | Stored {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 / condition | Behaviour |
|---|---|
| Render slots | 2 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. |
| Queue | 10 waiting jobs; full: 429 rate_limited, Retry-After: 30, message job queue full; nothing queued. |
| Retention | 15 min after done; failed status and error retained 15 min after failure. Queue wait does not consume retention. |
| Store | 256 MiB total. Reserve 80 MiB per accepted job until done, then count its real size until fetched or expired; failed jobs count nothing. |
| Per artefact | 80 MiB = 83,886,080 bytes. Oversized output fails with too_large; result answers 413, file deleted. |
| Store full | If stored + reserved + another 80 MiB exceeds 256 MiB: 429 rate_limited, Retry-After: 60, message job store full; nothing queued, no existing artefact evicted. |
| Shutdown | 30 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. |
| Restart | Queue, statuses and artefacts disappear; old ids answer 404. A 404 after 202 means resubmit. |
| Render budget | 180 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.
| Code | HTTP | Trigger |
|---|---|---|
| auth | 401 | Missing, malformed or unknown Authorization. Job relays with a missing, empty or malformed authenticated client label fail closed. |
| unsupported | 400 | Malformed 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. |
| unsupported | 404 | Unknown /v1 path; unknown, expired, fetched or another client’s job id. |
| asset_missing | 400 | Unresolved font or other non-data: reference; invalid base64, invalid image/font/video bytes, or inflated assets over the decode budget. |
| too_large | 413 | Body over the selected path limit; finished job artefact over its per-artefact limit. |
| rate_limited | 429 | Gateway 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_failed | 500 | Missing/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_failed | 504 | Render time budget exceeded; job browser-page acquisition timeout. |
Limits
| Limit | Gateway | Worker-direct |
|---|---|---|
| Request body | 4 MB | 32 MB |
| PDF screen duration | ~25 s before response starts | 60 s |
| PDF print duration | ~25 s before response starts | 120 s |
| Image duration | ~25 s before response starts | 30 s |
| Video duration | Synchronous form rejected; job result must start within ~25 s and may stream afterwards | 180 s; jobs count from rendering start |
| POST /v1/pdf per client | 30 / min | No per-client limit |
| POST /v1/image per client | 60 / min | No per-client limit |
| POST /v1/mix per client | 30 / min, shared with POST /v1/pdf | No per-client limit |
| Mix duration / concurrency | ~25 s before response starts | 30 s; 2 at once, a third is 429 |
| POST /v1/video jobs per client | 5 / min | No per-client limit |
| GET /v1/jobs/* per client | 60 / min, shared status/result bucket | No 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
| Trigger | Retry-After (seconds) |
|---|---|
| Gateway per-client rate limit | 60 |
| Worker browser queue full or slot wait exceeded / saturated pool | 5 |
| Synchronous video: two slots occupied or a job waiting | 10 |
| Job queue full | 30 |
| Job store full | 60 |
| Worker shutting down | 30 |
Response headers
| Header | Meaning / scope |
|---|---|
| Content-Type | application/pdf; image/png, image/jpeg or image/webp; video/mp4. Job/status/error JSON: application/json. |
| Content-Length | Whole artefact byte size, worker-direct only; absent on every gateway response. |
| X-Engine, X-Engine-Version | Renderer and its version: weasyprint for PDF, chromium for image/video. Version is the one used for that call. |
| X-Pages | PDF page count; image always 1. |
| X-Duration-Ms | Render wall time in milliseconds, not clip length. |
| X-Fonts-Embedded | PDF 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-SHA256 | PDF print only: lowercase SHA256 of the embedded named or inline ICC. |
| X-Clock, X-Frames | Video only: clock and frame count. |
| Retry-After | 429 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.