The production line, programmable
Everything the workspace does — upload, evaluate, improve, download — driven from your own systems over a simple JSON API.
The loop
The API mirrors the workspace: four steps, repeated per book.
Upload
Declare the file, PUT the bytes to a presigned URL, register the publication. Files never route through the API itself.
Run
Start any tool against the publication's working version. The response is a run you poll — sync tools finish on the first poll.
Read
Fetch the run's JSON report: findings, scores, metadata, or the publication-page description.
Download
Repairs and edits append new file versions. Download the working version via a short-lived link.
Base URL & authentication
Every request carries a workspace API key as a Bearer token. The key is bound to one workspace: everything you create and read through it lives there, exactly as if you were working in the app. There is no workspace parameter anywhere in this API — the key is what names the tenant, so a key can only ever reach its own library.
Base URL
https://origami.publica.la/api/v1
Every path on this page is relative to that. Requests and responses are JSON; send Accept: application/json and, on a POST, Content-Type: application/json.
A first call
curl https://origami.publica.la/api/v1/publications \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Accept: application/json"
Getting a key
A workspace owner or admin mints keys in Settings → API keys. The value is shown once and never again — only its hash is stored — so put it straight into your secret manager. The same screen revokes a key, and revocation takes effect on the next request.
A key belongs to the person who minted it as well as to the workspace. If that person leaves the workspace, their keys stop working with membership_revoked — so mint integration keys from an account that will outlive the integration.
Abilities
A key carries only the abilities it was minted with, and every route checks exactly one. A request that presents a key without the ability its route needs is refused with 403.
library:read
Read the workspace, its publications, file versions, tool runs, reports and reader mirrors.
library:write
Mint upload slots, register publications, and re-push a publication to the reader store.
tools:run
List the tool catalog and start tool runs.
Quickstart
Six requests take a book from your disk to a validation report and back. Replace the token and file name; everything else is copy-paste.
1 · Ask for an upload slot
curl -X POST https://origami.publica.la/api/v1/uploads \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"original_filename": "book.epub", "size_bytes": 4180294}'
Answers 201 with a publication_id, the storage key, the upload_url to PUT to, the headers that URL was signed with, and its expires_at — 30 minutes out.
2 · PUT the bytes to the presigned URL
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
-H "Content-Type: application/epub+zip" \
--data-binary @book.epub
Send every header the previous step returned, verbatim. The upload goes straight to storage: no file bytes ever pass through the API, so a 2 GB book uploads at the speed of the bucket rather than the speed of a PHP request.
3 · Register the publication
curl -X POST https://origami.publica.la/api/v1/publications \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"publication_id": "ID_FROM_STEP_1", "key": "KEY_FROM_STEP_1", "original_filename": "book.epub", "size_bytes": 4180294}'
Pass back the same publication_id, key, original_filename and size_bytes. The size is compared against what actually landed, so a truncated upload is refused here rather than discovered by the first tool run.
4 · Run a tool against it
curl -X POST https://origami.publica.la/api/v1/publications/PUBLICATION_ID/runs \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"tool": "epub-check"}'
Answers 202 with a run. A run always acts on the publication's working version — the newest file a tool produced, or the original if none has.
5 · Poll the run, then read the report
curl https://origami.publica.la/api/v1/runs/RUN_ID \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
curl https://origami.publica.la/api/v1/runs/RUN_ID/report \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
Poll until status is terminal. The report endpoint answers 409 run_not_finished while the run is still going, so polling the run first is the cheaper loop.
6 · Download the working version
curl https://origami.publica.la/api/v1/publications/PUBLICATION_ID/files/current \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
curl -L -o book-improved.epub \
https://origami.publica.la/api/v1/files/FILE_ID/download \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
The download endpoint answers a 302 to a presigned GET valid for 10 minutes. Follow redirects (curl -L) and do not cache the redirect target.
Endpoints
Every endpoint lives under /api/v1, speaks JSON, and answers an error as {error, reason, detail} with a stable machine reason. The ability column is the one each route checks.
Workspace
/api/v1/account
library:read
The workspace behind the key: plan, entitlement and publication allowance.
/api/v1/account/usage
library:read
Today's metered-tool usage against the daily limit, plus what's left of the publication allowance.
Request & response
Request
curl "https://origami.publica.la/api/v1/account" \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
Response
{
"data": {
"id": "01JQ8ZW4M3S9F0AC2KQ7X6HV1B",
"name": "Ediciones Ejemplo",
"type": "standalone",
"plan": { "slug": "pro", "name": "Pro" },
"subscribed": true,
"on_trial": false,
"entitled": true,
"unlimited": false,
"publications": {
"used": 2,
"limit": 5,
"remaining": 3,
"resets_at": "2026-10-08T00:00:00+00:00",
"granted_remaining": 0,
"promotional_remaining": 0
}
}
}
Publications & uploads
/api/v1/tools
tools:run
The tool catalog as the API exposes it, with metering flags.
/api/v1/uploads
library:write
Mint a presigned PUT for a new file (30 minutes).
/api/v1/publications
library:write
Register an uploaded object as a publication.
/api/v1/publications
library:read
List the workspace's publications (cursor pagination).
/api/v1/publications/{id}
library:read
One publication: type, status, title, working file.
Request & response
Request
curl -X POST "https://origami.publica.la/api/v1/uploads" \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"original_filename": "book.epub", "size_bytes": 4180294}'
Response
{
"data": {
"publication_id": "01JQ90R2VTA6E4M8YN5D3PKC7F",
"key": "accounts/01JQ8ZW4.../uploads/01JQ90R2....epub",
"upload_url": "https://storage.example/...&X-Amz-Signature=...",
"headers": { "Content-Type": "application/epub+zip" },
"expires_at": "2026-09-04T13:21:00+00:00"
}
}
File versions
/api/v1/publications/{id}/files
library:read
The full version history: the immutable original plus every improved copy.
/api/v1/publications/{id}/files/current
library:read
The working version — what the next tool run acts on.
/api/v1/files/{id}/download
library:read
302 to a short-lived download link for any version.
/api/v1/publications/{id}/reader
library:read
The publication's mirrors on the publica.la reader store, and whether the integration is configured.
/api/v1/publications/{id}/reader/sync
library:write
Re-push a mirror to the publica.la reader (202) — rescues a failed first publish or retries a stuck conversion.
Request & response
Request
curl "https://origami.publica.la/api/v1/publications/PUBLICATION_ID/files/current" \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}"
Response
{
"data": {
"id": "01JQ93B8KDW1H7RZ4T0NQXME62",
"publication_id": "01JQ90R2VTA6E4M8YN5D3PKC7F",
"role": "derived",
"derived_kind": "epub-repair",
"original_filename": "book.epub",
"mime_type": "application/epub+zip",
"size_bytes": 4194512,
"reverted": false,
"current": true,
"created_at": "2026-09-04T12:58:41+00:00"
}
}
Tool runs
/api/v1/publications/{id}/runs
tools:run
Start a tool run (202). Accepts an options object and an Idempotency-Key header.
/api/v1/publications/{id}/runs
library:read
List a publication's runs, filterable by tool.
/api/v1/runs/{id}
library:read
Run status, timing and error detail.
/api/v1/runs/{id}/report
library:read
The finished run's JSON report.
Request & response
Request
curl -X POST "https://origami.publica.la/api/v1/publications/PUBLICATION_ID/runs" \
-H "Authorization: Bearer ${ORIGAMI_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f3a-nightly-sweep-0042" \
-d '{"tool": "epub-accessibility"}'
Response
{
"data": {
"id": "01JQ95T1YB0PJ6VF8C3WKR2HQD",
"publication_id": "01JQ90R2VTA6E4M8YN5D3PKC7F",
"tool": "epub-accessibility",
"status": "queued",
"error_reason": null,
"error_detail": null,
"started_at": null,
"finished_at": null,
"duration_ms": null,
"created_at": "2026-09-04T13:02:10+00:00"
}
}
The four resources
Four shapes cover the whole API, and every endpoint that returns one returns it identically.
Workspace
Which workspace the key landed in, whether it may run paid work at all (entitled — wider than subscribed, because a publica.la store carries a tier with no Stripe subscription behind it), and the publication allowance. remaining sums the plan's cycle bucket, the slots the team granted and anything a promotion still covers — it is the only figure that answers "can I add another book". When Stripe cannot be reached, plan and publications come back null rather than zero: unknown, not exhausted.
Publication
A book, audiobook or source document in the workspace. current_file_id points at the working version, which is what the next tool run reads, and current_file_bytes is what that version weighs today — storage_bytes stays the size of the original as it was uploaded, so an optimized book reports two different numbers.
File version
One version of a publication. The original is immutable and never replaced; every tool that writes appends a new version, so the history is complete. current marks the working one and reverted marks a version rolled back in the app.
Tool run
One execution of one tool against one publication. status reaches succeeded, failed or rejected and then never changes; error_reason and error_detail say why on the other two.
Tool catalog
The slugs POST /publications/{id}/runs accepts, read live from this deployment — GET /api/v1/tools returns the same list as JSON. Two flags matter when you chain calls.
| Slug | Tool | Category | Flags |
|---|---|---|---|
epub-metadata |
EPUB Metadata Extractor | metadata | metered |
epub-description |
EPUB Description Writer | description | metered |
epub-metadata-write |
EPUB Metadata Editor | metadata | writes working file |
epub-toc |
EPUB Table of Contents | toc | metered |
epub-toc-generate |
EPUB Table of Contents Generator | toc | metered |
epub-toc-write |
EPUB Table of Contents Editor | toc | writes working file |
epub-check |
EPUB Checker | validation | |
epub-accessibility |
EPUB Accessibility Checker | accessibility | |
epub-repair |
EPUB Repair | repair | metered writes working file |
epub-qualebook |
Qualebook Quality Checklist | quality | |
epub-qualebook-review |
Qualebook AI Review | quality | metered |
cover-replace |
Cover Change | cover | writes working file |
onix-export |
ONIX File Creator | metadata | |
social-copy |
Social Campaign Writer | marketing | metered |
social-image |
Social Image Generator | marketing | metered |
social-carousel |
Social Carousel Generator | marketing | metered |
social-video |
Social Video Generator | marketing | metered |
pdf-check |
PDF Diagnosis | validation | |
pdf-accessibility |
PDF Accessibility Checker | accessibility | |
pdf-accessibility-fix |
PDF Accessibility Fix | accessibility | writes working file |
pdf-autotag |
PDF Auto-Tag | accessibility | metered writes working file |
pdf-alt-text |
PDF Alt Text | accessibility | metered writes working file |
pdf-repair |
PDF Repair | repair | metered writes working file |
asset-optimize |
Asset Optimizer | repair | metered writes working file |
pdf-epub-analyze |
PDF to EPUB Analyzer | conversion | |
pdf-epub-convert |
PDF to EPUB Converter | conversion | metered |
document-epub-convert |
Document to EPUB Converter | conversion | metered |
A slug this deployment does not expose answers 404 unknown_tool; one that exists but is not released yet answers 409 tool_unavailable. Options are tool-specific and passed as an options object — an option a tool does not recognize is ignored rather than refused, so check the report to confirm what actually ran. An option that would change what the run is allowed to do is the exception: an option that would make a run wait on a person, sent to a run that applies by itself, is refused with 422 invalid_options rather than quietly widening it.
Conventions
Async by default
Runs answer 202 with a resource you poll. A run is terminal at succeeded, failed or rejected. There are no webhooks yet, so poll — a second or two apart is plenty; nothing here finishes faster than the queue picks it up.
Idempotency
Send an Idempotency-Key header on POST /runs; a retry returns the original run with 200 instead of spending twice, and a retry that arrives while the first is still being recorded answers 409 idempotency_in_flight.
Pagination
Lists answer {data, next_cursor, has_more} with 50 rows a page, newest first. Pass next_cursor back as ?cursor= until has_more is false. The cursor is an id, not an offset, so a book added while you page cannot shift the window under you.
Rate limits
Requests are throttled at 60 a minute per key — per key, so two integrations on one workspace do not share a bucket. Over the ceiling is a 429; back off and retry.
Two spending ceilings
Uploads consume the plan's publication allowance (422 publication_quota_exceeded), and metered tools consume the workspace's daily fair-use allowance (429 daily_limit_reached with Retry-After). GET /account and GET /account/usage report both before you spend either.
Files move directly
Uploads are presigned PUTs and downloads presigned GETs against storage — the API never proxies file bytes, so transfers are fast at any size. Upload links live 30 minutes, download links 10.
Descriptions
The description tool returns the publication-page copy: one style (back_cover) with a long version for the page body and a short one for cards and metadata. The catalogue and bookseller styles the tool also writes stay inside the app.
Dates and ids
Every timestamp is ISO 8601 with an offset. Every id is a ULID — sortable, so ordering by id is ordering by creation.
Errors
Every failure answers the same envelope. reason is the stable machine string to branch on — the HTTP status tells you the class of problem, the reason tells you which one.
The envelope
{
"error": "Publication not found.",
"reason": "publication_not_found",
"detail": null
}
A malformed request body is Laravel's own 422 validation response ({message, errors}), not this envelope — the envelope is for failures the API decided on, the validation shape for a body it could not read.
| Status | Reason | What it means |
|---|---|---|
| 401 | invalid_token |
No key, an unknown key, or one that has been revoked. |
| 403 | token_without_workspace |
The key is not bound to a workspace — an operator key on a customer route. |
| 403 | membership_revoked |
The key's owner no longer belongs to this workspace. Mint a new key from a current member. |
| 404 | publication_not_found |
No publication with that id in this workspace. Ids are never shared across workspaces. |
| 404 | file_not_found |
No such file version, or the publication has no file yet. |
| 404 | run_not_found |
No run with that id in this workspace. |
| 404 | unknown_tool |
That slug is not a tool this API exposes. GET /tools is the authoritative list. |
| 409 | tool_unavailable |
The tool exists but is not released yet. |
| 409 | working_file_busy |
Another run that writes the working file holds this publication. It clears when that run finishes — no Retry-After can name when, so poll the run. |
| 409 | run_not_finished |
The report was asked for before the run reached a terminal status. |
| 409 | run_not_succeeded |
The run is terminal but did not succeed, so there is no report. The run resource says why. |
| 409 | approval_required |
This run's result needs a person to compare before/after examples, which only the Origami app can show. Waiting or subscribing does not clear it. |
| 409 | idempotency_in_flight |
A request with the same Idempotency-Key is still being recorded. Retry in a moment. |
| 409 | reader_not_configured |
This deployment has no publica.la reader integration. |
| 409 | reader_sync_in_progress |
A push of the same variant to the reader store is already in flight. |
| 409 | downloads_unavailable |
The storage backend cannot mint presigned links. Only ever seen on local development storage. |
| 422 | publication_quota_exceeded |
The workspace has spent its publication allowance for the cycle. The detail says when it renews. |
| 422 | unsupported_format |
Not a book, source document or audiobook format Origami accepts. |
| 422 | invalid_options |
The options object is not valid for that tool. |
| 402 | plan_required |
The workspace has no active plan, so no tool run may start. Read endpoints keep working — which is why this is not a 403. |
| 429 | daily_limit_reached |
The workspace hit its daily fair-use limit on metered runs. Carries Retry-After. |
Also over MCP
Origami speaks MCP (Model Context Protocol) at https://origami.publica.la/mcp over Streamable HTTP, so an agent can browse your workspace and run tools the same way this API does.
Connect with a workspace API key as a Bearer token, or via OAuth from clients that support it. Every tool mirrors one endpoint above — same service, same tenancy, same quotas — so an agent and an HTTP client see one product.
What you can do over MCPConnect from Claude Code
claude mcp add --transport http origami https://origami.publica.la/mcp \
--header "Authorization: Bearer ${ORIGAMI_API_KEY}"
Building an integration?
Tell us what you're automating — bulk ingestion, a DAM hook, a preflight pipeline — and we'll help you wire it.