Developer portal

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.

Start here

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.

Evidence-linked

Each field carries page + bbox citations and a calibrated confidence.

Human-in-the-loop

Field status tells you what a reviewer accepted, edited, or escalated.

RLS everywhere

Keys read only the documents your organisation can see.

Yours to own

Verified corrections become a dataset and, eventually, your own model.

Security

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 header·http
Authorization: Bearer alma_7Qb3kP9wY2hC8tD0vN5xM1aZ6sR4fL2gJ

Mint, 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.

Quick check·bash
$ 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_…" }
Contract

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 envelope·json
{
  "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.

Enterprise API v1

Upload & ingest

POST/api/v1/documents/upload-url
POST/api/v1/documents
GET/api/v1/documents?limit=&cursor=&status=&collection=
GET/api/v1/documents/{id}/status

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.

1 · Stage, 2 · PUT, 3 · Ingest·bash
$ 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.

Enterprise API v1

Jobs & the async contract

GET/api/v1/jobs?kind=&status=&limit=&cursor=
GET/api/v1/jobs/{id}

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.

Poll to terminal·bash
$ 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 internals

Terminal 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.

Enterprise API v1

Get a document

GET/api/v1/documents/{id}

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.

Request·bash
$ curl https://api.alma.intergentech.ai/api/v1/documents/4012 \
    -H "Authorization: Bearer $ALMA_API_KEY"
200 OK·json
{
  "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 } }
    }
  ]
}
Enterprise API v1

Export a dataset

GET/api/v1/export/{documentId}?format=csv|xlsx|xml|json|jsonl

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.

csvtext/csv
xlsxspreadsheet (binary)
xmlapplication/xml
jsonapplication/json
jsonlone example per line
Request·bash
$ curl -L https://api.alma.intergentech.ai/api/v1/export/4012?format=csv \
    -H "Authorization: Bearer $ALMA_API_KEY" \
    -o document-4012.csv
document-4012.csv·csv
field_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}",vision
Custom models

List custom models

GET/api/v1/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.

Request·bash
$ 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.
200 OK·json
{
  "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.

Custom models

Get a model

GET/api/v1/models/{id}

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.

Request·bash
$ curl https://api.alma.intergentech.ai/api/v1/models/9 \
    -H "Authorization: Bearer $ALMA_API_KEY"
200 OK·json
{
  "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 }.

Custom models

Digitize with your model

POST/api/v1/models/{id}/digitize

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.

Request (async + poll)·bash
$ 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" } }
200 OK (no Prefer header, finished within the wait window)·json
{
  "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 } }
    }
  ]
}
Deprecated

Legacy: POST /digitize

POST/api/v1/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.

Migrate·bash
# 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"
For agents

MCP tool surface

POST/api/mcp

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.

list_documents

Browse visible document instances

get_document_fields

Structured fields for one document

export_document

json · csv · xlsx (base64)

tools/call·bash
$ 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 }
      }
    }'
200 OK·json
{
  "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.

Push, not poll

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.

POST <your endpoint> · event body·json
{
  "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):

verify.ts·ts
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.

The flywheel

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.

  1. 1
    Verify

    Reviewers accept or edit fields in the workspace. Each verified field becomes a (page region → correct value) pair.

  2. 2
    Export the dataset

    Pull verified pairs with /api/v1/export/{documentId}?format=jsonl — image region, bbox, field key, doc type, and the human-verified text.

  3. 3
    Register 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.

  4. 4
    Call your model

    Route extraction through POST /api/v1/models/{id}/digitize. Same evidence-linked output — now read by a model that knows your archive.

Export verified training pairs·bash
$ 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.

API changes

Changelog

2026-07-21 — OpenAPI, docs & corpus sharing
  • 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}.
2026-07-21 — Model API
  • 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.
2026-07-21 — Jobs, ingest & key controls
  • 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}.