Sats docs

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

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