HTTP API (v1)
Base URL https://<host>/v1. JSON in and out unless stated. The OpenAPI document is generated by
springdoc at /v1/openapi.json and drawn at /docs/api-reference; this guide explains the behaviour
the spec cannot.
Authentication
| Caller | Mechanism |
|---|---|
| Customer systems | Authorization: Bearer sats_live_… or sats_test_… (API key per workspace) |
| Editor users | Session cookie SATS_SESSION after POST /v1/session; X-XSRF-TOKEN header (value of the XSRF-TOKEN cookie) on mutating requests |
Key scopes: render (render, layout, preview, validate, read renders) and admin
(everything, including templates, assets, keys). Keys are stored as
SHA-256(pepper + key); only the prefix and last 4 characters are kept in clear for display.
Keys look like sats_live_ + 40 base62 characters. The first workspace and its admin key are
created on the server host with
java -jar sats-server.jar bootstrap --workspace "Name" (needs the same SATS_* environment as
the server; other arguments such as --spring.profiles.active=dev are passed on). The key is
printed once. Admins manage further keys with GET/POST/DELETE /v1/keys
({"name": "...", "scope": "render" | "admin", "test": false}); the raw key is only in the
POST response.
Test keys (sats_test_) render with a diagonal "TEST" watermark and are not billed.
Editor sessions
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/session |
Sign in {"email", "password"} → user, current workspace, all workspaces |
| GET | /v1/session |
The signed-in user (401 when not signed in); also sets the CSRF cookie |
| PUT | /v1/session/workspace |
Switch workspace {"workspace": "ws_…"} (must be a member) |
| POST | /v1/session/password |
Set a password from a one-time link {"token", "password"} and sign in |
| POST | /v1/session/password-reset |
E-mail a link (valid 1 hour) to choose a new password {"email"}; always 202 |
| DELETE | /v1/session |
Sign out |
| GET | /v1/members |
Members of the current workspace |
| POST | /v1/members |
Owner: add {"email", "role": "owner" | "editor" | "viewer"} and e-mail them ("emailed": true); on servers without e-mail the response has a one-time setupUrl instead |
| DELETE | /v1/members/{userId} |
Owner: remove (not the last owner: 409 last_owner) |
With a session, every /v1 endpoint acts in the current workspace: owners and editors have the
admin scope, viewers render. Passwords need at least 10 characters. Errors: login_failed,
token_invalid, password_too_short, email_invalid, csrf_invalid, last_owner,
mail_unavailable, mail_failed.
There is no self-service signup: the operator creates workspaces (bootstrap), owners invite
members by e-mail.
Endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET/POST/DELETE | /keys |
admin | List, create, revoke API keys |
| POST | /renders |
render | Render one document |
| GET | /renders/{id} |
render | Metadata of a render (pages, size, warnings); never the PDF |
| POST | /layout |
render | PageModel JSON for a template version, the draft (admin) or an inline template |
| POST | /preview |
render | PNG of one page (?page=1&dpi=96) (phase 5) |
| GET | /templates |
render | List templates (id, name, latest version) |
| POST | /templates |
admin | Create from JSON (body = template, optional ?slug=) |
| GET | /templates/{id} |
render | Template metadata and versions |
| GET | /templates/{id}/draft |
admin | Current draft JSON |
| PUT | /templates/{id}/draft |
admin | Replace draft (If-Match with draft ETag) |
| POST | /templates/{id}/versions |
admin | Publish draft as an immutable version |
| GET | /templates/{id}/versions/{v} |
render | Published version JSON |
| POST | /templates/{id}/validate |
render | Validate data against the template, no render |
| POST | /assets |
admin | Upload font, image or PDF (multipart file), or import {"url"} |
| GET | /assets / DELETE /assets/{id} |
admin | List / delete |
| GET | /fonts/{fontRef} |
render | TrueType file behind a PageModel fontRef (builtin:Inter:400, asset:ast_…), for drawing the PageModel (editor); 404 not_found |
| GET | /usage?month=2026-10 |
admin | Documents and pages of a month (default: this month, Europe/Oslo): live, test and draft counts; only live is billable |
Templates are referenced by id (tpl_…) or by a unique slug set on the template.
Templates, drafts and versions
POST /templateschecks the body againsttemplate.schema.json(422template_invalidwitherrors[].path) and stores it as the draft. Slugs match[a-z0-9][a-z0-9-]{0,63}and are unique per workspace (409slug_taken).GET /templates/{ref}/draftreturns the draft with anETag.PUTneedsIf-Matchwith that ETag: without it 428precondition_required, with a stale one 412draft_conflict.POST /templates/{ref}/versionspublishes the draft as the next version number after semantic validation (422template_invalidwith every problem inerrors[]). Versions never change.GET /templates/{ref}/versions/{v}:vis a number orlatest; a template without versions gives 409template_not_published.POST /templates/{ref}/validatewith{"data": {…}, "version": "latest"}answers200 {"valid": bool, "errors": [], "warnings": []}; it does not render.
Assets
Uploads are optional per installation: a server without object storage answers POST /assets
with 503 assets_unavailable. Images, inserted PDFs and page backgrounds can always come in the
data instead, as https:// URLs (fetched for each render, same limits as below) or data: URIs.
Kinds come from the file contents: TrueType fonts (.ttf), PNG, JPEG, PDF. OpenType fonts with
CFF outlines are not supported yet (415 asset_unsupported); unreadable fonts give 422
font_invalid. POST /assets with JSON {"url": "https://…", "filename": "optional"} imports a
file once: HTTPS only, no private or link-local destinations, max
10 MB, 10 s, at most 3 redirects (fetch_blocked, fetch_failed).
POST /layout
{ "template": "invoice", "version": "latest" | 7 | "draft", "data": { }, "options": { "strict": false } }
{ "templateJson": { /* a whole template */ }, "data": { } }
version: "draft" needs the admin scope. The response is the page model:
pages with positioned boxes; each box has a type (text, rect, line, image).
POST /renders
POST /v1/renders
Authorization: Bearer sats_live_xxx
Accept: application/pdf
Content-Type: application/json
{
"template": "order-confirmation",
"version": "latest",
"data": { "order": { "no": "100234" } },
"options": {
"strict": false,
"pdfa": false,
"documentDate": "2026-10-11",
"metadata": { "title": "Ordre 100234", "author": "Byggevare Eksempel AS" },
"password": null
},
"output": { "filename": "ordre-100234.pdf" }
}
Nothing is stored: the PDF exists only in the response. Sats never stores rendered PDFs or the request data; it keeps only render metadata (id, template version, pages, bytes, duration, warnings) for usage and support.
Synchronous only: the caller waits for the PDF; large documents take 10–20 s, so set client and proxy timeouts above the render limit (60 s). There are no async jobs, batches, webhooks or idempotency keys; to make many documents, send many requests.
Accept: application/pdf returns 200 with the PDF
(Content-Disposition from output.filename, headers Sats-Render-Id and Sats-Pages);
otherwise 200 with the job JSON and the PDF as base64 in file (max 5 MB, else 422
inline_too_large: use Accept: application/pdf). output.delivery may be left out or
inline; anything else (such as url) is 422 delivery_unsupported. Renders longer than 60 s give
503 render_timeout, documents over 2 000 pages 422 page_limit; when every render slot is busy
the answer is 503 server_busy with Retry-After (retry then). options.metadata fills the PDF's
title, author, subject and keywords (each at most 1 000 characters; the title defaults to the
template name). options.password (at most 128 characters) encrypts the PDF with AES-256; such PDFs
are not byte-identical between renders. options.pdfa is not supported yet: true gives
422 pdfa_unsupported. Renders with test keys carry a diagonal "TEST" watermark on every
page. options.documentDate is @renderDate and the PDF date (default:
today in Europe/Oslo).
Job JSON:
{
"id": "rnd_01J…",
"status": "done",
"template": { "id": "tpl_…", "version": 7 },
"pages": 3,
"bytes": 84512,
"durationMs": 182,
"warnings": [{ "code": "missing_glyph", "path": "/parts/0/sections/1/elements/0", "detail": "U+2603" }],
"file": "JVBERi0xLjYK…",
"createdAt": "2026-10-11T00:42:00Z"
}
file is only in the POST /renders response; GET /renders/{id} returns the same JSON
without it.
Test renders of the draft: "version": "draft" renders the current draft and needs the
admin scope (editor users with the owner or editor role). It is fire-and-forget like any
render; its job JSON has no template.version.
Errors
RFC 9457 problem details:
{
"type": "https://docs.sats.example/errors/data_invalid",
"title": "Data does not match the template's data contract",
"status": 422,
"code": "data_invalid",
"errors": [
{ "path": "/order/lines/3/price", "code": "required", "detail": "price is required" }
],
"requestId": "req_01J…"
}
Stable codes (add, never rename): unauthorized, forbidden, not_found, template_invalid,
data_invalid, expression_syntax, unknown_path, asset_missing, fetch_blocked,
fetch_failed, pdf_encrypted, pdf_invalid, page_limit, render_timeout,
layout_not_converging, rate_limited, server_busy, pdfa_unsupported, idempotency_key_reused,
draft_conflict,
bad_request, method_not_allowed, unsupported_media_type, not_acceptable,
payload_too_large, internal_error, precondition_required, slug_invalid, slug_taken,
template_not_published, asset_unsupported, font_invalid, font_unavailable,
inline_too_large, delivery_unsupported, image_source_invalid, image_unsupported, unknown_variable,
type_mismatch, duplicate_id, unknown_style, style_cycle, unknown_font_family,
unknown_column, page_variable_in_flow, expression_too_long, expression_budget_exceeded,
list_too_long, login_failed, token_invalid, password_too_short, email_invalid,
csrf_invalid, last_owner, table_in_page_template.
Every response carries Sats-Request-Id; problems repeat it as requestId.
Limits (defaults, per plan)
| Limit | Default |
|---|---|
| Request body | 10 MB |
| Render (all renders are synchronous) | 60 s, 2 000 pages |
| Remote image/PDF fetch | 10 MB each, 10 s, 50 per render |
| Rate | 20 requests/s per API key, burst 50, shared by all servers; then 429 rate_limited with Retry-After |
| Result retention | none — PDFs are not stored |
Render metadata (GET /renders/{id}) |
the workspace's retention period, default 24 h; then 404. Usage counters are kept |