sugarcloud.ch  ·  HTTP API  ·  for consumers of the service

Converting documents over HTTP

A PDF or a scan goes in. An EPUB, Markdown, DocTags, a repaired PDF or the OCD working format comes out. Server to server, one key, nine routes — and only two of them do the work.

Service sugarcloud.chEngine jexter 1.0.0 Authority GET /api/targetsContact contact [at] sugarcube.ch

01What it is

One HTTP front over the jexter document engine. Everything the engine can produce, it produces here — a target added to the engine appears on this API the same day, with nothing to wire on either side.

Nine routes, set out as plates below. Each plate has the same anatomy: the signature, one sentence of purpose, then what you can pass and what comes back. Learn it once, read any of them at a glance.

Plate 0 the whole surface
routewhat it does
GET /api/health is it up — no key
GET /api/version which build answers, this service's and the engine's — no key
GET /api/targets what this deployment can produce
GET /api/journal the service's last 500 log lines — what the engine read, redrew, refused
GET /api/options the engine's option registry, with defaults
POST /api/convert the work, synchronously — one call, the bytes back
POST /api/jobs the work, asynchronously — submit and get an identifier
GET /api/jobs/{uid} where a job stands
GET /api/jobs/{uid}/result the finished artifact
DELETE /api/jobs/{uid} the job and its files, gone — once you have the result

The difference between the two that matter is only how long you are willing to hold a connection open. Below a few dozen pages, convert. Above, submit a job: a 1000-page book takes about 40 seconds, which no proxy or load balancer will hold.

02Your key

Every route needs it except /api/health and /api/version, which stay open so monitoring can reach them. Two header forms, both accepted:

Authorization: Bearer <key>
X-API-Key: <key>
Keep it server-side

The key is shown once, when it is minted, and only its hash is stored here — we cannot read it back to you. Lose it and we mint another; it takes a second, and the old one is revoked. This API is not meant to be called from a browser, and a key in a page is a key given away.

03Five minutes

Is it up?

curl https://sugarcloud.ch/api/health
ok

Which build answers?

curl https://sugarcloud.ch/api/version
{"name":"sugarcloud.ch","commit":"1a35bfb","built":"2026-10-22T10:09:01Z",
 "engine":{"version":"1.1.0","commit":"601255b","built":"2026-10-22T10:08:33Z"}}

Turn a PDF into an EPUB and save it:

curl -H "Authorization: Bearer $KEY" \
     --data-binary @book.pdf \
     "https://sugarcloud.ch/api/convert?to=epub" -o book.epub

The same book, as Markdown for an index or a search engine — change one word:

"https://sugarcloud.ch/api/convert?to=md" -o book.md

That is the whole API for a small document. The rest of this page is for big ones, and for knowing what went wrong.

04Convert, now

Plate 1 POST/api/convert?to=<target>

The document in, the artifact out, in one call.

you sendform
the documentthe request body, raw — a PDF, an image or an .ocd.epub
the target?to=epub, required — see section 07
optionsany key of the registry, &selectable=true — see section 08
HTTP/1.1 200 OK
Content-Type: application/epub+zip
Content-Disposition: attachment; filename="document.epub"

The body has no name, so the artifact is called document: name the file yourself (-o book.epub).

An .ocd.epub is re-exported rather than re-imported, which is faster and lossless; an image (PNG, JPEG, TIFF) is read by OCR. The input is read, never declared — we look at the first bytes, so the content type does not have to be right.

The limit is the wait, not the size

Nothing caps a document at a page count, but a conversion that runs longer than your client, your proxy or your load balancer will hold is a conversion you never receive.

05Convert, as a job

Plate 2 POST/api/jobs?to=<target>

The same call, when the conversion outlives the request. Answers at once with an identifier.

UID=$(curl -s -H "X-API-Key: $KEY" \
      --data-binary @book.pdf "https://sugarcloud.ch/api/jobs?to=epub" | jq -r .uid)

202  {"uid":"ffc0…","state":"QUEUED"}
Plate 3 GET/api/jobs/{uid}

Where it stands. Poll every second or two; there is no rate limit on it.

{"uid":"ffc0…","state":"RUNNING","percent":42}
{"uid":"ffc0…","state":"DONE","percent":100,"filename":"book.epub","mediaType":"application/epub+zip"}
statemeaning
QUEUEDaccepted, waiting for a worker
RUNNINGconverting; percent is moving
DONEthe artifact is ready
FAILEDthe conversion threw; error says what
STALEit was converting and went silent: given up on
Plate 4 GET/api/jobs/{uid}/result

The artifact itself, named like a synchronous one. Before DONE, a 409.

Plate 5 DELETE/api/jobs/{uid}

The job, its upload and its artifact, gone — say so once you have the result. A job still converting answers 409.

A job is your key's: another key is told it does not exist (404).

A restart does not lose your job

If this service is redeployed mid-conversion, the job goes back in the queue at boot with the options you asked for. You keep polling the same uid and nothing else changes. A finished job nobody removed is swept after a week.

06In your code

The whole job loop — submit, poll, take the result, remove the job — as one function you can paste into your server. It runs as it stands against this service; only the key is yours, read from the environment (SUGARCLOUD_KEY). An error raises with the service's own message: 400: unknown target "epub3" (GET /api/targets).

Python — requests

import os, time, requests

API = "https://sugarcloud.ch/api"
KEY = {"X-API-Key": os.environ["SUGARCLOUD_KEY"]}    # from the server's configuration, never a page

def call(method, path, **kw):
    r = requests.request(method, API + path, headers=KEY, **kw)
    if r.status_code >= 400:
        raise RuntimeError(f"{r.status_code}: {r.json()['error']}")
    return r

def convert(path, to, **options):
    """A PDF, an image or an .ocd.epub in, the artifact out — as a job, whatever its size."""
    with open(path, "rb") as f:
        uid = call("POST", "/jobs", params={"to": to, **options}, data=f).json()["uid"]
    try:
        while True:
            job = call("GET", f"/jobs/{uid}").json()
            if job["state"] == "DONE":
                return call("GET", f"/jobs/{uid}/result").content
            if job["state"] in ("FAILED", "STALE"):
                raise RuntimeError(job.get("error") or job["state"])
            time.sleep(1)
    finally:
        try: call("DELETE", f"/jobs/{uid}")          # done with it; a weekly sweep takes what is left
        except RuntimeError: pass

open("book.epub", "wb").write(convert("book.pdf", "epub"))

Node 18 or later — fetch, nothing to install

import { readFile, writeFile } from 'node:fs/promises';

const API = 'https://sugarcloud.ch/api';
const KEY = { 'X-API-Key': process.env.SUGARCLOUD_KEY };  // from the server's config, never a page

async function call(method, path, body) {
  const res = await fetch(API + path, { method, body, headers: KEY });
  if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
  return res;
}

/** A PDF, an image or an .ocd.epub in, the artifact out — as a job, whatever its size. */
export async function convert(bytes, to, options = {}) {
  const query = new URLSearchParams({ to, ...options });
  const { uid } = await (await call('POST', `/jobs?${query}`, bytes)).json();
  try {
    for (;;) {
      const job = await (await call('GET', `/jobs/${uid}`)).json();
      if (job.state === 'DONE') {
        const result = await call('GET', `/jobs/${uid}/result`);
        return Buffer.from(await result.arrayBuffer());
      }
      if (job.state === 'FAILED' || job.state === 'STALE') throw new Error(job.error ?? job.state);
      await new Promise((r) => setTimeout(r, 1000));
    }
  } finally {
    await call('DELETE', `/jobs/${uid}`).catch(() => {});  // done with it; a sweep takes the rest
  }
}

await writeFile('book.md', await convert(await readFile('book.pdf'), 'md'));

Options are the query: convert("book.pdf", "pdf", selectable="true") in Python, convert(bytes, 'pdf', { selectable: 'true' }) in Node. For a short document /api/convert is one call instead, the same body and query, the bytes back.

07Targets

GET /api/targets is the authority — it lists what this deployment can produce today. At the time of writing:

targetwhat it is
epubEPUB 3 fixed-layout — the page as it looks, with real selectable text.
epub-reflowEPUB 3 reflowable — built from the document's structure, the reader chooses the typography.
pdfa normalized PDF: repaired fonts, regular structure. Add &selectable=true for a real text layer.
htmlthe text as one web page, from the structure tree: headings and paragraphs.
mdclean Markdown — for indexing, search, LLM ingestion.
doctagsDocling DocTags, with position boxes. For grounded LLM/RAG use.
svgone page as SVG; pick it with &page=0.
ocdthe working format: a valid EPUB that is also the lossless model. Send it back later and it is re-exported, not re-imported.
auditJSON: is this PDF really redacted? What is still under the black boxes.
stagesJSON: the geometry of each analysis stage. A diagnostic.
zonesJSON: where a regular expression matches, as rectangles. Pair it with &match= — it is how you find what to redact before you redact it.
patcha zip of only the members a redaction changed. For a consumer holding the .ocd already: apply the patch instead of moving the whole document twice.

08Options

GET /api/options returns the registry itself — key, label, help, type and default — so a UI can build itself and a client never guesses. Every query parameter other than to is passed straight to the engine.

keyeffect
selectablePDF target: embed fonts and place real text. Off = a pixel-exact facsimile.
pageSVG target: which page, 0-based.
navPerPageEPUB: a navigation entry for every page, even one with no heading.
pagesthe page set as a result: 1,4-6,2 keeps those, in that order — omission deletes, repetition duplicates.
redactaudit, or an explicit page:x,y,w,h;… list. What is under a zone is removed, not covered, and the engine refuses to write unless its own audit of the result is clean.
ocrPrepan image source: zigzag-gray flattens uneven light, shadows and printed backgrounds before the OCR reads it (zigzag-gray-despeckle also drops specks). The page keeps the original picture.
ocrLang · imageDpian image source: a language hint for the OCR, and the resolution the picture is taken to have (it sets the page size).
gridDocTags: the integer grid the position boxes quantize onto.
An unknown key is ignored in silence

Nothing refuses it, and nothing happens. Spell it exactly as /api/options gives it — renderAnnotations, not annotations. The registry also lists two keys marked "local": true — addFont, addFontChars: they name a file on the machine running the engine, and a request that sets them is refused (400).

09Errors

One shape, on every route:

{"error":"unknown target \"epub3\" (GET /api/targets)"}
codewhen
400no target or an unknown one, a value that does not parse (page=abc), a local option, no document in the request
401missing or invalid key
404no such route, or no such job
405right route, wrong method (/api/convert is a POST)
409the job has no result yet, or is still converting when you remove it
413the body is over 512 MB (section 10)
500the conversion threw; the message names the exception
503every conversion slot is busy: a synchronous /api/convert waits a little, then says so (Retry-After). Retry, or submit a job, which waits its turn instead. Also an image when the service has no OCR engine bound: it is refused, never returned without its text

A failed job carries the same text in its error field.

10Limits

What this deployment holds to. None of it is a page count: a book is the unit.

limitvaluepast it
a request body512 MB413
conversions at once2, synchronous and queued alikea job waits its turn; /api/convert waits up to 30 s, then 503 with Retry-After: 10
polling a jobno limit — every second or two is right—
jobs in flight, per key20 queued or converting429 with Retry-After: 30 — take or remove some first
a converting job gone silent10 minutes (a live one says so every minute)STALE
a finished job nobody removedkept 7 daysswept, then 404

A key, or a limit that does not fit your documents: contact [at] sugarcube.ch.