Build on a pipeline that verifies, not just generates.
alma turns documents into structured, evidence-linked fields you can trust. Authenticate with a Bearer API key, call the Enterprise API v1 from any language, or wire the MCP tool surface straight into your agents. Every field comes back with confidence, review status, and page/bbox citations.
Overview
The alma API is organised around documents. A documentId is a document_instance — one logical record extracted from an uploaded file. Every endpoint returns JSON unless you explicitly request a binary export. All requests are made over HTTPS and authenticated with a Bearer API key.
Each field carries page + bbox citations and a calibrated confidence.
Field status tells you what a reviewer accepted, edited, or escalated.
Keys read only the documents your organisation can see.
Verified corrections become a dataset and, eventually, your own model.
Authentication
Machine-to-machine calls authenticate with a Bearer API key. Tokens look like alma_… and are shown to you exactly once at creation — alma stores only a salted hash, never the raw token. Send it in the Authorization header on every request.
Authorization: Bearer alma_7Qb3kP9wY2hC8tD0vN5xM1aZ6sR4fL2gJMint, list, and revoke keys from the admin console (admin role required). Each key carries a role — the ceiling on what it can do — and optionally a set of scopes that narrow it further. Reads need viewer+; writes need editor+. Scopes subtract from the role and never add to it: a viewer key with models:run still cannot run a model. Keys minted without scopes have full access at their role.
Scopes: documents:read, documents:write, export:read, corpus:read, models:read, models:run, jobs:read. A call outside the key's scopes gets 403 with code missing_scope naming the missing scope.
The one exception: corpus:read. The flywheel training corpus (format=jsonl on the export endpoint) normally requires an admin-role key. A key explicitly minted with corpus:read may read the corpus whatever its role — so an admin can share corpus access without sharing admin. Only a project admin can attach this scope at mint; it grants jsonl only and does not replace export:read for the file formats.
Expiry & rotation. A key can carry an expiry date; past it, requests get 401 like any invalid key. To rotate, mint a new key and revoke the old one.
Rate limits. Each key has a budget of 120 requests/minute by default (a per-key override can be set at mint). Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds); over budget you get 429 with code rate_limited and a Retry-After header — back off until it elapses.
Idempotent retries. Every job-creating POST (POST /api/v1/documents, POST /api/v1/documents/{id}/digitize, POST /api/v1/models/{id}/digitize) honors an Idempotency-Key header: retrying with the same key and body replays the original response instead of re-running the pipeline (replays carry Idempotent-Replayed: true). Reusing a key with a different body is 422 idempotency_key_reuse; a duplicate sent while the first is still running is 409 idempotency_in_flight. Keys are kept for 24 hours.
$ curl https://api.alma.intergentech.ai/api/v1/models \
-H "Authorization: Bearer $ALMA_API_KEY"
# A 401 means the token is missing, malformed, revoked, or expired:
# { "error": "unauthorized", "code": "unauthorized", "request_id": "req_…" }Errors & envelopes
Every error response carries the same stable envelope. Branch on code — the machine-readable discriminator — never on the prose. The request_id is echoed in the x-request-id header on every response; quote it to support and we can find the exact request in our logs.
{
"error": "documentId (positive integer) is required",
"code": "validation_error",
"request_id": "req_5f1d3c33-8f6a-4b6e-9d2e-1a2b3c4d5e6f"
}Codes: unauthorized · forbidden · insufficient_role · missing_scope · validation_error · not_found · model_not_ready · document_not_ready · concurrent_run · rate_limited · idempotency_in_flight · idempotency_key_reuse · not_enabled · internal_error. 500s are always scrubbed — the body never contains internals.
Successful responses carry RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset and x-request-id too — see Authentication for the budget rules.
Upload & ingest
Getting a document into alma is three calls: mint a presigned upload slot, PUT the file bytes, then ingest the staged key — which enqueues a digitize job on the durable worker unless you pass digitize: false. Ingest is idempotent on content: the same bytes always resolve to the same document_id. Both writes need an editor+ key with documents:write.
$ curl -X POST https://api.alma.intergentech.ai/api/v1/documents/upload-url \
-H "Authorization: Bearer $ALMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "deed-0042.pdf" }'
# { "bucket": "…", "key": "documents/<project>/…/deed-0042.pdf",
# "token": "…", "upload_url": "https://…" }
$ curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @deed-0042.pdf
$ curl -X POST https://api.alma.intergentech.ai/api/v1/documents \
-H "Authorization: Bearer $ALMA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "key": "documents/<project>/…/deed-0042.pdf", "collection": "deeds-1888" }'
# 202 Accepted
# Operation-Location: /api/v1/jobs/6f9d…
# Retry-After: 5
# { "document_id": 4012, "sha256": "…", "page_count": 12,
# "job_id": "6f9d…", "status": "queued", "status_url": "/api/v1/jobs/6f9d…" }Poll GET /api/v1/documents/{id}/status for live pipeline progress from the moment of ingest (it never 404s while recognition runs), and GET /api/v1/documents to list what you have ingested — keyset-paginated: limit (default 50, max 200) plus the opaque next_cursor from the previous page. Re-run the pipeline any time with POST /api/v1/documents/{id}/digitize.
Jobs & the async contract
Pipeline runs execute asynchronously as jobs. Every job-creating POST answers 202 { job_id, status, status_url } with an Operation-Location header, and honors Prefer: wait=n (cap 60 — a job finishing inside the window returns 200 with the full job view) and Prefer: respond-async (always the immediate 202). Posting the same work twice never duplicates it: the second call returns the already-open job.
$ curl https://api.alma.intergentech.ai/api/v1/jobs/6f9d… \
-H "Authorization: Bearer $ALMA_API_KEY"
# open → { "id": "6f9d…", "kind": "digitize", "status": "processing",
# "document_id": 4012, … } (+ Retry-After: 5)
# done → { "id": "6f9d…", "kind": "digitize", "status": "succeeded",
# "result": { "document_id": 4012, "pages": 12, "fields": 34,
# "doc_type": "deed_of_conveyance",
# "result_url": "/api/v1/documents/4012" } }
# failed → { …, "status": "failed", "error": { "code": "worker_terminated",
# "message": "…" } } — error is typed, never raw internalsTerminal statuses are succeeded, failed, and canceled; a job can never be stuck processing forever — provably-dead jobs are failed with the typed worker_terminated error. Reads need jobs:read. Prefer push over polling? Terminal events are also delivered to your webhooks.
Get a document
The structured projection of a digitized document: each field returns the best available value — the reviewer-accepted value where present, otherwise the highest-confidence machine reading — alongside its calibrated confidence, review status (unreviewed · accepted · edited · rejected · escalated), and page/bbox evidence. Nothing to read until extraction lands — poll the status endpoint first. Requires documents:read.
$ curl https://api.alma.intergentech.ai/api/v1/documents/4012 \
-H "Authorization: Bearer $ALMA_API_KEY"{
"documentId": 4012,
"docType": "deed_of_conveyance",
"fields": [
{
"key": "grantor_name",
"value": "Josiah A. Greaves",
"confidence": 0.973,
"status": "accepted",
"evidence": { "page": 1, "bbox": { "x": 0.142, "y": 0.331, "w": 0.268, "h": 0.041 } }
}
]
}Export a dataset
Download a document's accepted fields as a file. Choose the format with the query parameter; the response sets Content-Disposition so it saves with a sensible filename. Use jsonl to pull verified training pairs for the flywheel (see Bring your own model).
Access. The file formats need a viewer+ key with export:read. The jsonl training corpus (verified text + page-image references) needs an admin-role key — or a key of any role explicitly minted with the corpus:read scope, the share-without-admin grant only a project admin can attach.
$ curl -L https://api.alma.intergentech.ai/api/v1/export/4012?format=csv \
-H "Authorization: Bearer $ALMA_API_KEY" \
-o document-4012.csvfield_key,value,confidence,page_number,bbox,source
grantor_name,Josiah A. Greaves,0.973,1,"{""x"":0.142,""y"":0.331,""w"":0.268,""h"":0.041}",accepted
parcel_acreage,12.5,0.61,2,"{""x"":0.557,""y"":0.214,""w"":0.083,""h"":0.029}",visionList custom models
List the fine-tuned models registered to your project. Each model has a stable numeric id you can inspect (next section) and route extraction through. Models you have not registered are never returned. The list includes models in every lifecycle state — draft, dataset_ready, training, ready, failed — so you can watch a model move through training. Narrow it with ?status= (an unknown status is a 400).
Migration note (2026-07-21). This endpoint previously returned only ready models. It now returns all statuses by default. If your integration iterates the list and invokes each entry, filter on status === "ready" (or request ?status=ready) before calling digitize — non-ready models answer 409 model_not_ready.
$ curl "https://api.alma.intergentech.ai/api/v1/models?status=ready" \
-H "Authorization: Bearer $ALMA_API_KEY"
# Omit ?status= to see every model, including ones still training.{
"models": [
{
"id": 7,
"name": "Barbados deeds — extraction v2",
"status": "ready",
"base_model": "claude-haiku",
"provider": "anthropic",
"kind": "extraction",
"version": "abc123",
"dataset_count": 4820,
"created_at": "2026-05-14T09:21:00Z"
},
{
"id": 9,
"name": "Barbados handwriting adapter",
"status": "ready",
"base_model": "trocr-base-handwritten",
"provider": "custom",
"kind": "htr",
"version": "barbados-v0",
"dataset_count": 1190,
"created_at": "2026-06-20T16:02:00Z"
}
]
}kind is extraction (invokable through model digitize) or htr — a registered handwriting adapter whose identity and metrics are visible here, but which cannot be invoked directly yet; HTR adapters already do their work inside the standard pipeline.
Get a model
Everything the list row carries, plus the opaque model_ref, the training-set size (dataset_count, counted from your verified corrections at snapshot time), and metrics — recorded evaluation results such as character error rates. Metrics are honest: the object is empty until an evaluation has actually been recorded for the model — values are never fabricated. A model id outside your project is a plain 404.
$ curl https://api.alma.intergentech.ai/api/v1/models/9 \
-H "Authorization: Bearer $ALMA_API_KEY"{
"id": 9,
"name": "Barbados handwriting adapter",
"status": "ready",
"base_model": "trocr-base-handwritten",
"provider": "custom",
"kind": "htr",
"version": "barbados-v0",
"dataset_count": 1190,
"created_at": "2026-06-20T16:02:00Z",
"updated_at": "2026-07-02T10:12:00Z",
"model_ref": "https://recognition.internal/adapters/barbados-v0",
"metrics": {
"cer_handwritten": 0.114,
"cer_typed": 0.021,
"cer_delta_vs_base": -0.81,
"benchmark_at": "2026-07-02T10:12:00Z"
},
"docs_url": "https://api.alma.intergentech.ai/developers#models"
}A failed model additionally carries error: { code, message }.
Digitize with your model
Run the full pipeline on a document but route the LLM extraction step through one of yourfine-tuned models. The run executes asynchronously on alma's durable worker as a job. Only ready extraction models can be invoked — anything else is 409 model_not_ready. Control the response contract with the standard Prefer header:
- Prefer: respond-async — immediate 202 { job_id, status, status_url } with an Operation-Location header; poll GET /api/v1/jobs/{job_id} until the job is terminal, then fetch the document from result.result_url.
- Prefer: wait=n (cap 60) — block up to n seconds; if the job finishes inside the window you get 200 with the job view (result included), otherwise the 202 shape above.
- No Prefer header (legacy default, deprecated) — behaves as Prefer: wait=60, and a within-window success returns the full structured document (same shape as before, additively carrying model_id and job_id) so existing integrations keep working unchanged.
Deprecation notice. The implicit-sync default (calling without a Prefer header) is deprecated: those responses carry an RFC 9745 Deprecation header (and an RFC 8594 Sunset header once the flip date is announced). At sunset the no-header default becomes an immediate 202. Migrate by sending an explicit Prefer header and polling. Also note: documents that take longer than 60 seconds — which previously blocked up to 300s for a synchronous 200 — now return 202 at the end of the wait window; poll the status_url to completion.
$ curl -X POST https://api.alma.intergentech.ai/api/v1/models/7/digitize \
-H "Authorization: Bearer $ALMA_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: respond-async" \
-H "Idempotency-Key: 8f7c2e9a-run-4012" \
-d '{ "documentId": 4012 }'
# 202 Accepted
# Operation-Location: /api/v1/jobs/6f9d…
# { "job_id": "6f9d…", "status": "queued", "status_url": "/api/v1/jobs/6f9d…" }
$ curl https://api.alma.intergentech.ai/api/v1/jobs/6f9d… \
-H "Authorization: Bearer $ALMA_API_KEY"
# … repeat until status is "succeeded", then fetch result.result_url
# { "id": "6f9d…", "kind": "model_digitize", "status": "succeeded",
# "result": { "document_id": 4012, "model_id": 7, "result_url": "/api/v1/documents/4012" } }{
"documentId": 4012,
"docType": "deed_of_conveyance",
"model_id": 7,
"job_id": "6f9d…",
"fields": [
{
"key": "grantor_name",
"value": "Josiah A. Greaves",
"confidence": 0.991,
"status": "unreviewed",
"evidence": { "page": 1, "bbox": { "x": 0.142, "y": 0.331, "w": 0.268, "h": 0.041 } }
}
]
}Legacy: POST /digitize
Deprecated — sunset 2026-10-01. This endpoint was a misnamed read: it never ran the pipeline, it returned already-extracted fields. Its replacement is GET /api/v1/documents/{id} — the identical response shape. The body keeps working until sunset; every response already carries an RFC 9745 Deprecation header, an RFC 8594 Sunset header with the date above, and a Link rel="deprecation" pointing here. On 2026-10-01 the endpoint is removed — migrate before then.
# Before (deprecated):
$ curl -X POST https://api.alma.intergentech.ai/api/v1/digitize \
-H "Authorization: Bearer $ALMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "documentId": 4012 }'
# ← responses carry: Deprecation: @… Sunset: 2026-10-01
# After (drop-in — same response shape):
$ curl https://api.alma.intergentech.ai/api/v1/documents/4012 \
-H "Authorization: Bearer $ALMA_API_KEY"MCP tool surface
alma speaks the Model Context Protocol over a JSON-RPC 2.0 HTTP endpoint (Streamable-HTTP transport, POST only). Point an MCP-capable agent at it to give the model first-class tools for reading your archive. The handshake is initialize, discover tools with tools/list, and invoke them with tools/call.
Browse visible document instances
Structured fields for one document
json · csv · xlsx (base64)
$ curl -X POST https://api.alma.intergentech.ai/api/mcp \
-H "Authorization: Bearer $ALMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_document_fields",
"arguments": { "documentId": 4012 }
}
}'{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{ \"documentId\": 4012, \"docType\": \"deed_of_conveyance\", \"fields\": [ ... ] }"
}
]
}
}The MCP server reports protocol version 2025-06-18 and supports initialize, ping, tools/list, and tools/call.
Webhooks
When a job reaches a terminal state, alma POSTs a signed event to every enabled HTTPS endpoint configured for your project (project admins manage endpoints under API keys & webhooks). Two event types exist — job.completed and job.failed — and the data.kind field discriminates what kind of job finished (digitize · model_digitize · import): one mechanism for every job type. Webhooks are an accelerator — polling GET /api/v1/jobs/{id} always works.
{
"type": "job.completed",
"timestamp": "2026-07-21T12:00:00Z",
"data": {
"job_id": "9c3f65be-…",
"kind": "digitize",
"status": "succeeded",
"project_id": "b0e2…",
"document_id": 4012,
"model_id": null,
"result_url": "https://api.alma.intergentech.ai/api/v1/documents/4012"
}
}Deliveries are signed per Standard Webhooks v1: each request carries webhook-id, webhook-timestamp, and webhook-signature headers. Verify with the off-the-shelf standardwebhooks library and the endpoint's signing secret (shown once, at creation):
import { Webhook } from "standardwebhooks"; // npm i standardwebhooks
const wh = new Webhook(process.env.ALMA_WEBHOOK_SECRET); // "whsec_…"
// In your handler: raw body string + the three webhook-* headers.
// verify() throws on a bad signature or a stale timestamp.
const event = wh.verify(rawBody, {
"webhook-id": req.headers["webhook-id"],
"webhook-timestamp": req.headers["webhook-timestamp"],
"webhook-signature": req.headers["webhook-signature"],
});Respond 2xx within 10 seconds. Anything else is retried on the schedule 5s · 5m · 30m · 2h · 5h · 10h · 10h (8 attempts, ~28h), then dead-lettered — admins can redrive dead-lettered deliveries from the delivery log. Retries reuse the same webhook-id, so dedupe on it. Endpoints must be public HTTPS (no redirects followed); delivery logs are retained 30 days.
Bring your own model
Every correction a reviewer makes is a labelled example you own. alma turns that stream of verified fields into a model tuned to your documents — so accuracy compounds with use.
- 1Verify
Reviewers accept or edit fields in the workspace. Each verified field becomes a (page region → correct value) pair.
- 2Export the dataset
Pull verified pairs with /api/v1/export/{documentId}?format=jsonl — image region, bbox, field key, doc type, and the human-verified text.
- 3Register a fine-tuned model
Train on your dataset and register the result. It appears in /api/v1/models with a stable id through every lifecycle state — invoke it once status is ready.
- 4Call your model
Route extraction through POST /api/v1/models/{id}/digitize. Same evidence-linked output — now read by a model that knows your archive.
$ curl -L "https://api.alma.intergentech.ai/api/v1/export/4012?format=jsonl" \
-H "Authorization: Bearer $ALMA_API_KEY" \
-o deeds.jsonl
# Each line is one verified example:
# {"text":"Josiah A. Greaves","field_key":"grantor_name","doc_type":"deed_of_conveyance","page_number":1,"bbox":{"x":0.142,"y":0.331,"w":0.268,"h":0.041},"image_key":"pages/4012/p1.png","status":"accepted"}The data, the corrections, and the resulting model are yours — self-hostable in your own environment. alma is the process that gets you there.
Changelog
- Published the OpenAPI 3.1 contract at GET /api/v1/openapi.json (generated from the same schemas the routes validate with) and an interactive reference with a try-it console at /developers/reference.
- New corpus:read scope: share flywheel-corpus (format=jsonl) access without granting the admin role. Attachable at mint by project admins only; existing admin-role keys are unchanged.
- Sunset date announced for the deprecated POST /api/v1/digitize: 2026-10-01 (RFC 8594 Sunset header now on every response). Migrate to GET /api/v1/documents/{id}.
- GET /api/v1/models now returns models in all lifecycle states (previously ready only) and supports ?status=. If you iterate-and-invoke, filter status === "ready" first. Rows gain provider, kind, version, dataset_count, created_at (additive).
- New GET /api/v1/models/{id} — detail with model_ref and honest metrics (empty until an evaluation is recorded).
- POST /api/v1/models/{id}/digitize now runs asynchronously on the jobs machinery and honors Prefer: respond-async / wait=n. Calls without a Prefer header keep the synchronous full-document 200 (implicit wait=60) for a deprecation window — responses carry Deprecation/Sunset headers. Behavior change: runs longer than 60s (previously up to 300s synchronous) now return 202 — poll the status_url.
- New machine ingest (POST /api/v1/documents/upload-url, POST /api/v1/documents) and jobs surface (GET /api/v1/jobs, GET /api/v1/jobs/{id}).
- Key scopes, expiry, per-key rate limits, and Idempotency-Key support on job-creating POSTs.
- POST /api/v1/digitize (the misnamed read) is deprecated — use GET /api/v1/documents/{id}.