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.
| route | what 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>
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
The document in, the artifact out, in one call.
| you send | form |
|---|---|
| the document | the request body, raw — a PDF, an image or an .ocd.epub |
| the target | ?to=epub, required — see section 07 |
| options | any 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.
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
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"}
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"}
| state | meaning |
|---|---|
QUEUED | accepted, waiting for a worker |
RUNNING | converting; percent is moving |
DONE | the artifact is ready |
FAILED | the conversion threw; error says what |
STALE | it was converting and went silent: given up on |
The artifact itself, named like a synchronous one. Before DONE, a
409.
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).
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:
| target | what it is |
|---|---|
epub | EPUB 3 fixed-layout — the page as it looks, with real selectable text. |
epub-reflow | EPUB 3 reflowable — built from the document's structure, the reader chooses the typography. |
pdf | a normalized PDF: repaired fonts, regular structure. Add
&selectable=true for a real text layer. |
html | the text as one web page, from the structure tree: headings and paragraphs. |
md | clean Markdown — for indexing, search, LLM ingestion. |
doctags | Docling DocTags, with position boxes. For grounded LLM/RAG use. |
svg | one page as SVG; pick it with &page=0. |
ocd | the working format: a valid EPUB that is also the lossless model. Send it back later and it is re-exported, not re-imported. |
audit | JSON: is this PDF really redacted? What is still under the black boxes. |
stages | JSON: the geometry of each analysis stage. A diagnostic. |
zones | JSON: where a regular expression matches, as
rectangles. Pair it with &match= — it is how you find what to redact
before you redact it. |
patch | a 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.
| key | effect |
|---|---|
selectable | PDF target: embed fonts and place real text. Off = a pixel-exact facsimile. |
page | SVG target: which page, 0-based. |
navPerPage | EPUB: a navigation entry for every page, even one with no heading. |
pages | the page set as a result: 1,4-6,2
keeps those, in that order — omission deletes, repetition duplicates. |
redact | audit, 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. |
ocrPrep | an 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 · imageDpi | an image source: a language hint for the OCR, and the resolution the picture is taken to have (it sets the page size). |
grid | DocTags: the integer grid the position boxes quantize onto. |
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)"}
| code | when |
|---|---|
400 | no target or an unknown one, a value that does not
parse (page=abc), a local option, no document in the request |
401 | missing or invalid key |
404 | no such route, or no such job |
405 | right route, wrong method
(/api/convert is a POST) |
409 | the job has no result yet, or is still converting when you remove it |
413 | the body is over 512 MB (section 10) |
500 | the conversion threw; the message names the exception |
503 | every 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.
| limit | value | past it |
|---|---|---|
| a request body | 512 MB | 413 |
| conversions at once | 2, synchronous and queued alike | a job waits its
turn; /api/convert waits up to 30 s, then 503 with
Retry-After: 10 |
| polling a job | no limit — every second or two is right | — |
| jobs in flight, per key | 20 queued or converting | 429
with Retry-After: 30 — take or remove some first |
| a converting job gone silent | 10 minutes (a live one says so every minute) | STALE |
| a finished job nobody removed | kept 7 days | swept, then
404 |
A key, or a limit that does not fit your documents: contact [at] sugarcube.ch.