Read + write surface with the current classification model.
Status: This is the active and only public API surface. (The legacy versioned API has been retired.) Owner: Branko · Last reviewed: 2026-06-23 Source of truth:
app/api/v2/**route handlers + this file. Taxonomy tables auto-generated from the database viatsx scripts/generate-taxonomy-docs.ts.
Machine-readable spec. An OpenAPI 3.1 description of every endpoint (incl. webhooks) is published as
/openapi-v2.yamland/docs/openapi.json. Import either into Postman or Insomnia, or feed it to an OpenAPI client generator (e.g.openapi-generator) to scaffold a typed client. The spec is hand-maintained, Redocly-linted in CI, and guarded against endpoint drift by a contract test (e2e/contracts/openapi-spec.spec.ts) that fails if a route and the spec disagree. This document remains the authoritative reference for semantics, vocabularies and examples.
Get from zero to a delivered webhook in five steps.
A platform SuperAdmin creates your organization in the iDMS dashboard and assigns you as Org Admin. Then go to Settings → API Keys and create a key. The plaintext is shown once — copy it immediately. Keys live in idms_… format and are scoped to your organization.
Send the key on every request as a Bearer token:
curl -H "Authorization: Bearer idms_…" \
https://idms.mory.ai/api/v2/documents
Use external_id as a stable client-side identifier so retries don't create duplicates:
curl -X POST https://idms.mory.ai/api/v2/documents/upload \
-H "Authorization: Bearer idms_…" \
-F "file=@invoice-001.pdf" \
-F "external_id=client-system-2024-1041"
Second call with the same external_id returns the existing document with meta.idempotent_replay: true — no 409, no duplicate document.
In Settings → Webhooks, register your endpoint and check the events you want. Webhooks are POSTed with Content-Type: application/json, an X-IDMS-Signature header (HMAC-SHA256, hex), and an X-IDMS-Event header.
// Express — verify over the RAW body, constant-time. See Webhooks → Verifying the signature.
import crypto from "node:crypto";
app.post("/idms-webhook", express.raw({ type: "*/*" }), (req, res) => {
const expected = crypto
.createHmac("sha256", process.env.IDMS_WEBHOOK_SECRET)
.update(req.body) // req.body is the raw Buffer, not parsed JSON
.digest("hex");
const sig = req.headers["x-idms-signature"];
const ok = sig && crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString()); // parse only AFTER verifying
// ... handle event.event
res.status(200).end();
});
document.processed eventWhen the AI pipeline finishes, your endpoint receives:
{
"event": "document.processed",
"timestamp": "2026-06-07T08:00:00.000Z",
"document_id": "550e8400-…",
"external_id": "client-system-2024-1041",
"filename": "invoice-001.pdf",
"doc_type": "invoice",
"doc_subtype": "service_invoice",
"tags": ["plumbing"],
"extracted_metadata": { "betrag": "256.74", "currency": "CHF", "iban": "CH93 …" }
}
That's it. Pull the document state from GET /api/v2/documents/{document_id} if you need more than the webhook carries.
This is a read + write + upload API with the current classification model: endpoints for reading documents with the full classification surface, PATCH endpoints for manual corrections, idempotent upload, per-client output profiles, and five webhook event types. All routes live under /api/v2/*.
Highlights:
absender / empfaenger / cc).PATCH for classification / metadata / contacts / tags + bulk PATCH + tag add/remove — every write goes into an append-only audit log.external_id or repeated content hash returns the existing document; no 409 on duplicate filename (designed for high-volume bulk ingestion of 200 000+ files).GET /api/v2/documents?updated_since=<ts> plus the meta.server_time cursor lets you poll only what changed.abgeschlossen / finalized / deleted reject writes with 400 DOCUMENT_LOCKED. Reads always succeed.document.failed, classification.changed, tag.suggested, usage.threshold.reached) with an HMAC-SHA256 envelope.One key pool, one set of limits — the API key from Settings → API Keys works on every /api/v2/* endpoint with no extra setup.
Authorization: Bearer idms_<api_key>.404, never 200 with foreign data.429):
X-RateLimit-Limit: 100X-RateLimit-Remaining: <int>X-RateLimit-Reset: <unix seconds>Field-name note. Some JSON field names carry a
_v3suffix —document_classification_v3,intent_v3,v1_projection. Treat that suffix purely as a stable contract identifier, not an API version: a field named with_v3belongs to the current classification model, andv1_projectionmaps each value back to the legacy 16-type taxonomy that the API still surfaces unchanged. The suffix never changes, so you can hard-code these field names safely. The rest of this document just calls it "the classification model."
The classification model is opt-in per organization. By default an org runs the legacy extractor — the legacy model is the safe default so existing integrations never get a surprise upgrade. Opting in flips the org to the current model and adds the richer fields to every new document.
What your integration sees depends on your org's flag:
| Field on the read response | Org on the legacy default | Org opted in (current model) |
|---|---|---|
Top-level doc_type, doc_subtype, doc_intent | legacy vocabulary, populated (invoice, service_invoice, …) | legacy-vocabulary projection of the current-model value (same vocabulary, same semantics — backward-compat fallback) |
Nested document_classification_v3 | null — pipeline never ran the current extractor | populated with doc_type, doc_bereich, doc_subtype, intent_v3, status_lifecycle, zahlungsstatus (German wire values) |
| Tag fields | legacy vocabulary | legacy vocabulary kept, plus the richer per-document state in document_classification_v3 |
If your client code only reads the top-level fields, you'll see the legacy vocabulary regardless of opt-in — that's the backward-compat surface. To consume the richer model, read from the nested document_classification_v3 object. The mapping back to the legacy vocabulary for every current-model value is the v1_projection column in the taxonomy tables further down.
How to opt in. Contact your account manager. We flip a config flag and run a one-off reclassifier over your existing documents so the nested document_classification_v3 rows appear retroactively. No code change on your side — the surface stays the same, the nested object just becomes non-null.
Already opted in. A number of production organizations are already running the current model; the rest stay on the legacy default until they opt in as described above.
The model defines four orthogonal dimensions every document is classified across, plus a top-level Bereich for human-readable grouping in dashboards and reports.
| Dimension | What it answers | Cardinality |
|---|---|---|
Dokumententyp (doc_type) | What kind of document is this? | one |
Untertyp (doc_subtype) | Which variant within the type? | one (optional, depends on doc_type) |
| Tags | What is it about (substantively)? | many (1–5) |
Intent (intent_v3) | What does the recipient need to do? | one |
Plus structured extracted fields common to every document: status_lifecycle, zahlungsstatus, betrag, currency, date_issued, booking_date, vat_rate, due_date, iban, signature, and linked entities (Bezüge) — property, unit, equipment.
A Bereich (area / domain) is purely a navigational grouping — the AI picks exactly one doc_type, and that type belongs to exactly one bereich. The 8 Bereiche cover the real-estate management domain (Finanzen / Finance, Beschaffung / Procurement, Verträge / Contracts, Immobilie / Property, Korrespondenz / Correspondence, Personal / HR, Vermarktung / Marketing, Auffang / Catch-all).
Several enum values stay German on the wire because they originated from the Swiss property-management domain and are stable contract identifiers — renaming them would break every integrator. Labels and surrounding prose are translated; the values themselves are not. The table below is the canonical mapping between the German wire values and their English meaning.
| Field | German wire value | English meaning |
|---|---|---|
zahlungsstatus | offen | open / unpaid |
zahlungsstatus | bezahlt | paid |
zahlungsstatus | teilbezahlt | partially paid |
zahlungsstatus | ueberfaellig | overdue |
zahlungsstatus | sonstiges | other / fallback |
status_lifecycle | neu | new — just received, not yet triaged |
status_lifecycle | in_bearbeitung | in progress — being worked on |
status_lifecycle | abgeschlossen | completed / locked — terminal write-state |
direction (routing) | eingehend | inbound — addressed to the property manager |
direction (routing) | ausgehend | outbound — issued by the property manager |
direction (routing) | neutral | neither inbound nor outbound |
contact role | absender | sender (from-party) |
contact role | empfaenger | receiver (to-party) |
contact role | cc | carbon copy |
These values are German because they reflect the Swiss real-estate domain vocabulary that originated this dataset; they are stable contract identifiers and will not be renamed even as labels and prose get English translations.
<!-- BEGIN AUTO-TAXONOMY -->Auto-generated from the taxonomy reference tables — DO NOT EDIT BY HAND. Run
tsx scripts/generate-taxonomy-docs.tsto refresh after a taxonomy migration. Current sizes: 8 bereiche, 31 doc_types, 121 doc_subtypes, 13 intents, 10 tag_groups, 54 tags.
finanzen)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
rechnung | Rechnung | Invoice | schlussrechnung, teilrechnung, akontorechnung, sammelrechnung, qr_rechnung | invoice |
gutschrift | Gutschrift | Credit Note | — | credit_note |
mahnung | Mahnung | Payment Reminder | — | reminder |
betreibung | Betreibung | Debt Collection | zahlungsbefehl, rechtsvorschlag, fortsetzungsbegehren, verlustschein | debt_collection |
quittung | Quittung | Receipt | schluesselquittung | receipt |
finanzbericht | Finanzbericht | Financial Statement | bilanz, erfolgsrechnung, jahresabschluss, quartalsabschluss, budget, kontoblatt, offene_posten_liste, liegenschafts_abrechnung, nebenkostenabrechnung_hknk, stwe_abrechnung | financial_statement |
bankdokument | Bankdokument | Bank Document | kontoauszug, zahlungsauftrag, dauerauftrag, zinsabrechnung, einzahlungsschein_wir | statement |
steuerdokument | Steuerdokument | Tax Document | steuererklaerung, steuerrechnung, steuerveranlagung, steuerwertschaetzung | statement |
finanzierungsdokument | Finanzierungsdokument | Financing Document | hypothekarvertrag, hypothek_offerte, schuldbrief, darlehensvertrag, kyc_unterlagen | contract |
beschaffung)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
offerte | Offerte | Quotation / Offer | — | quote |
bestellung_auftragsbestaetigung | Bestellung/Auftragsbestätigung | Purchase Order / Confirmation | bestellung, auftragsbestaetigung, submission_ausschreibung | quote |
lieferschein | Lieferschein | Delivery Note | — | delivery_note |
vertraege)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
vertrag | Vertrag | Contract | bewirtschaftungsauftrag, servicevertrag, wartungsvertrag, hauswartungsvertrag, mietvertrag, dienstbarkeitsvertrag, werkvertrag, nachtrag, aufhebung, kaufvertrag, arbeitsvertrag, rahmenvertrag, verwaltungsvertrag | contract |
reglement | Reglement | Regulation / Bylaws | nutzungsordnung, verwaltungsordnung, begruendungsurkunde, hausordnung | contract |
behoerdenentscheid | Behördenentscheid | Official Decision | verfuegung, bewilligung, urteil_entscheid, einsprache | other |
grundbuchauszug | Grundbuchauszug | Land Registry Extract | — | other |
versicherung | Versicherung | Insurance Document | gebaeudeversicherungsausweis, einzelauszug_sach, einzelauszug_haftpflicht, police, schadensmeldung, versicherungsfall | insurance |
immobilie)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
plan | Plan | Plan / Drawing | katasterplan, grundrissplan, gis_auszug, schliessplan | report |
zertifikat | Zertifikat | Certificate | sn_niederspannung, sn_brennerkontrolle, sn_anlageninbetriebnahme, sicherheitsschein, garantieschein, energieausweis_geak | report |
bericht | Bericht | Report | fact_sheet, zustandsanalyse, inspektionsbericht, wartungsbericht, schadensbericht, pflichtenheft, controlling_bericht, mieterspiegel, leerstandsliste, projektdatenblatt, terminplan_bauprogramm, baubeschrieb | report |
protokoll | Protokoll | Minutes / Protocol | bauabnahme, uebergabe, begehung, generalversammlung, ausschusssitzung, sitzungsprotokoll, schluesselprotokoll | minutes |
foto | Foto | Photo | — | other |
korrespondenz)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
brief | Brief | Letter | eigentuemerkorrespondenz, behoerdenkorrespondenz, mieterkorrespondenz, revisionsweisung, kuendigung, einladung_traktandenliste | letter |
e_mail | — | letter | ||
formular | Formular | Form | amtl_mietzinsaenderung, amtl_kuendigungsformular, mietbewerbung_anmeldeformular | form |
visitenkarte | Visitenkarte | Business Card | — | form |
personal)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
personaldokument | Personaldokument | HR Document | lohnabrechnung, lohndeklaration, arbeitszeugnis, bewerbung_lebenslauf, spesenabrechnung, unfallmeldung, stellenbeschrieb | other |
firmenunterlagen | Firmenunterlagen | Company Document | handelsregisterauszug, betreibungsauszug, statuten_gruendungsakte, aktienzertifikat, vollmacht | other |
ausweisdokument | Ausweisdokument | Identity Document | id_pass, aufenthaltsbewilligung, strafregisterauszug | other |
vermarktung)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
marketing_inserat | Marketing/Inserat | Marketing / Listing | inserat, expose_teaser, broschuere_prospekt, kampagne | other |
auffang)| doc_type | Label (DE) | Label (EN) | Subtypes | Legacy projection |
|---|---|---|---|---|
sonstiges | Sonstiges | Other | — | other |
| intent (key) | Label (DE) | Label (EN) | Legacy projection |
|---|---|---|---|
sonstiges | Sonstiges | Other | information |
ablegen | Ablegen | File / Archive | record_keeping |
antworten | Antworten | Reply / Respond | action_required |
bezahlen | Bezahlen | Pay | payment_required |
geltend_machen | Geltend machen | Assert claim | action_required |
genehmigen | Genehmigen | Approve | approval_needed |
kontakt_erfassen | Kontakt erfassen | Capture contact | record_keeping |
kuendigen | Kündigen | Cancel / Terminate | action_required |
pruefen | Prüfen | Review / Verify | action_required |
unterschreiben | Unterschreiben | Sign | action_required |
verbuchen | Verbuchen | Post (accounting) | record_keeping |
verlaengern | Verlängern | Extend / Renew | action_required |
weiterleiten | Weiterleiten | Forward | action_required |
anlagen_gebaeudeteile)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
heizung | Heizung | Heating |
dach | Dach | Roof |
fassade | Fassade | Facade |
sanitaer | Sanitär | Sanitary |
schliessanlage | Schliessanlage | Locking system |
aufzug | Aufzug | Elevator |
bodenbelag | Bodenbelag | Floor covering |
elektro | Elektro | Electrical |
fenster_tueren | Fenster & Türen | Windows & Doors |
lueftung_klima | Lüftung & Klima | Ventilation & Climate |
hausdienste)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
reinigung | Reinigung | Cleaning |
abfallentsorgung | Abfallentsorgung | Waste disposal |
hauswartung | Hauswartung | Caretaking |
schaedlingsbekaempfung | Schädlingsbekämpfung | Pest control |
schluesseldienst | Schlüsseldienst | Locksmith service |
umgebungspflege | Umgebungspflege | Grounds maintenance |
winterdienst | Winterdienst | Winter service |
sicherheit)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
brandschutz | Brandschutz | Fire safety |
ueberwachung | Überwachung | Surveillance / Monitoring |
energie_versorgung)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
gas | Gas | Gas |
solar | Solar | Solar |
strom | Strom | Electricity |
wasser | Wasser | Water |
zaehlerablesung | Zählerablesung | Meter reading |
finanzen)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
hypothek | Hypothek | Mortgage |
bank | Bank | Bank |
akontozahlung | Akontozahlung | Down payment |
buchhaltung | Buchhaltung | Bookkeeping |
budget | Budget | Budget |
finanzierung | Finanzierung | Financing |
steuern | Steuern | Taxes |
recht_verwaltung)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
bewilligung | Bewilligung | Permit / Authorisation |
eigentuemer | Eigentümer | Owner |
garantie | Garantie | Warranty / Guarantee |
streitfall | Streitfall | Dispute / Litigation |
mieter_mietverhaeltnis)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
mietzinsanpassung | Mietzinsanpassung | Rent change |
einzug | Einzug | Move-in |
auszug | Auszug | Move-out |
kaution | Kaution | Deposit / Security |
mieteranliegen | Mieteranliegen | Tenant request |
mieterwechsel | Mieterwechsel | Tenant change |
untermiete | Untermiete | Subletting |
bau_renovation)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
kueche | Küche | Kitchen |
badezimmer | Badezimmer | Bathroom |
balkon_terrasse | Balkon/Terrasse | Balcony / Terrace |
bauarbeiten | Bauarbeiten | Construction work |
malerarbeiten | Malerarbeiten | Painting work |
renovation | Renovation | Renovation |
personal_firma)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
firmenorganisation | Firmenorganisation | Company organisation |
lohn | Lohn | Salary / Wages |
personal | Personal | HR / Staff |
verkauf_einkauf)| tag (key) | Label (DE) | Label (EN) |
|---|---|---|
einkauf | Einkauf | Procurement |
verkauf | Verkauf | Sale |
vermietung_vermarktung | Vermietung/Vermarktung | Letting / Marketing |
Two enum-valued fields ride alongside the classification axes. Both are writable through the PATCH endpoints and seeded by the AI pipeline.
status_lifecycle| Value | Meaning |
|---|---|
neu | Newly ingested, not reviewed |
in_bearbeitung | A human is actively working on it |
abgeschlossen | Reviewed and accepted — locks the document for writes |
sonstiges | Other / unclassified state |
finalized | External system marked complete — locks |
deleted | Soft-deleted — locks |
zahlungsstatus (only meaningful for Finanzen Bereich docs)| Value | Meaning |
|---|---|
offen | Unpaid |
bezahlt | Paid in full |
teilbezahlt | Partial payment |
ueberfaellig | Overdue |
sonstiges | Other / NA |
Every doc_type and intent row in the reference tables carries a v1_projection column so legacy-vocabulary receivers can interpret a classification from the current model. Read responses also surface the original legacy columns (doc_type, doc_subtype, doc_intent) alongside the current-model fields — see Documents — read.
All read endpoints share the standard envelope { data, meta, error } and the rate-limit headers above.
/api/v2/documentsList documents in the caller's organization, with the current classification fields nested alongside the legacy envelope.
Query parameters (all optional):
| Parameter | Type | Vocabulary | Description |
|---|---|---|---|
page | int | — | Page number (default 1, max 10000). |
per_page | int | — | Results per page (default 25, max 100). |
doc_type | string | legacy | Exact match on documents.doc_type (legacy type, e.g. invoice). |
subtype | string | current | Exact match on the current-model subtype (German wire value from the nested object, e.g. qr_rechnung). |
intent | string | current | Exact match on the current intent (e.g. payment_required). |
status | string | legacy | Exact match on documents.status (legacy pipeline status, e.g. completed). |
zahlungsstatus | string | current | One of offen, bezahlt, teilbezahlt, ueberfaellig, sonstiges. |
bereich | string | current | Exact match on the current Bereich (e.g. finanzen). |
tag | string | current | Tag key from the controlled taxonomy. Repeatable for OR semantics: ?tag=plumbing&tag=heating. |
betrag_min | number | current | Minimum amount. Filters on the extracted total (betrag / total_amount). Combine with betrag_max for a range. |
betrag_max | number | current | Maximum amount. Filters on the extracted total (betrag / total_amount). |
faellig_after | date | current | Due date on/after this date (ISO YYYY-MM-DD). Filters on the extracted due date. |
faellig_before | date | current | Due date on/before this date (ISO YYYY-MM-DD). Filters on the extracted due date. |
external_id | string | — | Exact match on the caller-supplied external_id — look a document up by your own reference. |
updated_since | timestamp | — | Returns documents with updated_at strictly after this ISO-8601 timestamp, ordered updated_at ascending — delta sync. Pair with the meta.server_time cursor (below). A malformed value returns 400. |
profile | string | — | Apply an output profile. Profile key is provisioned by your account manager. |
The amount and due-date filters are all optional and combinable with every other filter. Parsing is server-side; a document whose stored due date is calendar-invalid is treated as a non-match rather than causing an error.
Example:
curl -H "Authorization: Bearer idms_..." \
"https://idms.mory.ai/api/v2/documents?doc_type=invoice&zahlungsstatus=offen&tag=plumbing"
Returns paginated data: [...] with meta: { page, per_page, total, total_pages, server_time }. server_time is the server's query time (ISO-8601 UTC) — for delta sync, save it and pass it as the next updated_since so no change is missed between polls. Each row has the legacy envelope fields plus document_classification_v3, document_tags, document_tags_v3.
/api/v2/documents/:idRetrieve the full document with the legacy envelope, the full classification record, structured metadata, related contacts, and linked entities — property / unit / equipment (Bezüge).
Returns the same row shape as the list endpoint, plus a contacts: [...] array of normalized Person/Firma entities each with their role (absender / empfaenger / cc) — full field reference under GET /api/v2/documents/:id/contacts.
Extracted entity fields (top-level on every document row; each is null / [] when the document doesn't reference one):
| Field | Shape | Description |
|---|---|---|
sender | { name, address?, reference_id? } | The entity that ISSUED the document (the "From") |
receiver | { name, address?, reference_id? } | The entity ADDRESSED in the document (the "To") |
related_contacts | [{ name, role?, reference_id? }] | Contacts mentioned but neither sender nor receiver (the "CC") |
property | { name, address?, reference_id? } | Real-estate property referenced in the document |
unit | { name, type?, property_reference? } | Sub-unit within a property (apartment, parking, storage) |
equipment | { name, type?, reference_id? } | Specific asset referenced (heating, elevator, …) |
signature | { signed, signed_by?, signed_at?, confidence?, raw? } | Contract signing status (see the Signature changelog entry) |
sender / receiver / related_contacts are lightweight extraction snapshots embedded in the document row. The org-level deduplicated contact records (with emails, phones, registry numbers, …) live on the contacts array / endpoint below.
When called with ?profile=…, the response is rewritten per that profile — see Output Profiles.
/api/v2/documents/:id/contactsStandalone contacts list for one document. Identical data to the embedded contacts array on the detail endpoint — separated so clients can refresh just the contacts without re-pulling the full body.
Contact object fields:
| Field | Type | Description |
|---|---|---|
id | uuid | Org-level contact id (see stability note below) |
kind | string | person or firma (legal entity) |
role | string | Role on THIS document: absender / empfaenger / cc |
name | string | Display name ("Mark Müller" / "Müller AG") |
first_name, last_name, salutation | string | null | Person only |
date_of_birth | string | null | Person only — ISO YYYY-MM-DD |
function | string | null | Person only — role/job title as stated in documents (e.g. "Geschäftsführerin") |
email, phone | string | null | Primary email / phone |
emails, phones | string[] | All known emails / phones |
address | string | null | Full address as one string |
website, vat_number, hr_number | string | null | Firma only — website, UID/VAT, commercial-register number |
external_id | string | null | Caller-supplied id when the contact was imported |
{
"data": [
{
"id": "f7a34862-122b-4d45-b89c-836a9d715f28",
"kind": "person",
"role": "empfaenger",
"name": "Christian Käppen",
"first_name": "Christian",
"last_name": "Käppen",
"salutation": "Herr",
"date_of_birth": "1981-08-16",
"email": "c.kaeppen@example.ch",
"emails": ["c.kaeppen@example.ch"],
"phone": null,
"phones": [],
"address": "Ledergasse 11, 6004 Luzern",
"website": null,
"vat_number": null,
"hr_number": null,
"external_id": null
}
]
}
Contact id stability: contacts are deduplicated org-wide; when duplicates are merged, the surviving contact keeps its id and the merged duplicates' ids disappear. Treat document_id as your stable key and re-fetch contacts rather than persisting contact_id long-term. Document ids never change.
/api/v2/documents/:id/downloadReturns a short-lived signed URL that points at the document's original file bytes on Supabase Storage. The bytes never travel through this API — the client follows the URL directly to Storage (CDN-fronted, no extra auth).
Why a signed URL instead of streaming the bytes: avoids the serverless function duration ceiling on large downloads, keeps the function cheap, and lets the client hand the URL to any download tool unchanged.
Query params
| Name | Type | Default | Notes |
|---|---|---|---|
ttl | int (seconds) | 3600 | URL validity. Clamped silently to [60, 86400] — out-of-range values are not rejected. |
Response (200)
{
"data": {
"url": "https://<project>.supabase.co/storage/v1/object/sign/documents/...",
"expires_at": "2026-06-08T15:30:00.000Z",
"filename": "rechnung-q3.pdf",
"content_type": "application/pdf",
"file_size": 1842
},
"meta": { "ttl_seconds": 3600 },
"error": null
}
Client flow
url=$(curl -sS -H "Authorization: Bearer $IDMS_API_KEY" \
"$IDMS_BASE_URL/api/v2/documents/$DOC_ID/download" | jq -r .data.url)
curl -o "$DOC_ID.pdf" "$url"
Errors
| Status | Body | When |
|---|---|---|
| 401 | Invalid or missing API key | Missing or unknown Bearer token |
| 404 | Document not found | Document does not exist OR belongs to another org (no leak) |
| 429 | Rate limit exceeded. | 100 req/min/key cap |
| 500 | signed URL generation failed: … | Supabase Storage signing call returned an error |
Cross-org isolation: the lookup is scoped by both id AND organization_id. A key from one org asking for a document that belongs to another org receives 404, not 403 — existence is never disclosed.
Sammel-PDF caveats (relevant only if the document went through A2 split):
is_container = true) point at the original multi-document PDF — caller receives the whole bundle.source_document_id != null) share the parent's storage_path — caller receives the same bundle. The per-segment text lives in extracted_content on the child row and is already exposed through GET /api/v2/documents/:id; the original file is not re-encoded per child.All PATCH endpoints:
400 DOCUMENT_LOCKED when the document's status_lifecycle is abgeschlossen / finalized / deleted. Reads still succeed.document_audit_log (actor, IP, prev/next snapshots, optional reason).Only the top-level PATCH /api/v2/documents/:id fires classification.changed webhook events — one per touched axis (see Webhooks). The sub-resource (/classification, /metadata, /tags) and bulk endpoints update the record and write the audit row but do not emit the event.
/api/v2/documents/:idAtomic top-level correction. Update classification axes and tags in one call.
{
"doc_type": "rechnung",
"doc_subtype": "service_invoice",
"doc_bereich": "finanzen",
"intent": "payment_required",
"status_lifecycle": "in_bearbeitung",
"zahlungsstatus": "bezahlt",
"tags": ["plumbing", "heating"],
"reason": "Manual correction after operator review"
}
All fields optional. Tags are replaced (final array = desired state). Unknown taxonomy keys return 422 — see Error codes.
/api/v2/documents/:id/classificationFine-grained classification + tag UPSERT. Same body shape as the top-level PATCH but scoped to the classification axes. Useful when you want a separate audit row for classification-only edits.
/api/v2/documents/:id/metadataMERGE update of document_classification_v3.extracted_metadata (JSONB). Specified keys overwrite, keys with null are deleted, unspecified keys remain. The legacy documents.extracted_metadata is not touched.
{
"reference_number": "RG-2026-001-CORRECTED",
"betrag": "215.50",
"due_date": "2026-07-10",
"qr_verified": null,
"reason": "Corrected per supplier clarification"
}
/api/v2/documents/:id/contactsREPLACE the document's contact link rows. Payload is the desired final state — links not in the payload are deleted, new links are inserted, identical links unchanged.
{
"contacts": [
{ "contact_id": "contact-uuid-1", "role": "absender" },
{ "contact_id": "contact-uuid-2", "role": "absender" }
],
"reason": "Added secondary sender contact"
}
Anti-spoof: every contact_id is verified to belong to the caller's organization — out-of-org → 400 (same shape as "unknown contact" so no RLS leak).
/api/v2/documents/:id/tagsAdd one or more tags from the controlled taxonomy to the document. Body shape: { "tags": ["plumbing", "heating"] }. Idempotent UPSERT with confidence = 1 (manual sentinel). Unknown tag → 422. Optional X-Audit-Reason header attaches a reason to the audit row.
/api/v2/documents/:id/tagsRemove one or more tags. Body shape: { "tags": ["plumbing"] }. Silent no-op when the tag isn't present (idempotent).
/api/v2/documents (bulk)Up to 100 items per request. Each item is processed independently — one bad item never aborts the rest.
{
"items": [
{ "id": "<doc-1>", "doc_type": "rechnung", "tags": ["plumbing"], "reason": "..." },
{ "id": "<doc-2>", "zahlungsstatus": "bezahlt" }
]
}
Returns per-item result with status: "ok" | "error" and meta: { total, ok, error }.
/api/v2/documents/uploadUpload a single file with content-hash + external_id idempotency. Designed for high-volume bulk ingestion (200 000+ files): repeated filenames are NOT a duplicate signal — only external_id and content hash are.
Multipart fields:
| Field | Required | Description |
|---|---|---|
file | yes | The document file (PDF, image, Office) |
external_id | recommended | Caller's stable id — primary idempotency key |
metadata | no | JSON string with caller-side context, stored on the document |
force | no | "true" bypasses content-hash idempotency and processes the file as a NEW document (testing aid — see below) |
Size limit: 4 MB per file. Our serverless runtime caps multipart request bodies at 4.5 MB before they reach this handler — that limit is platform-level and not application-configurable. We pin the application-level limit to 4 MB so the descriptive 413 from this endpoint surfaces before the generic infrastructure 413. Pre-validate file size client-side before posting.
Files larger than 4 MB must use the presigned upload flow described below — that path streams directly to storage and lifts the per-file limit to 250 MB.
Idempotency / dedup decision:
upload request
│
▼
external_id provided?
│ yes │ no
▼ │
SELECT (org, ext_id) │
│ │
found? │
│ yes │
▼ │
return existing │
meta.idempotent_replay │
= true, │
dedup_by = "external_id"│
│ no │
└────────┬─────────┘
▼
compute sha256(content)
│
▼
SELECT (org, content_hash)
│
found?
│ yes
▼
return existing
meta.idempotent_replay = true,
dedup_by = "content_hash"
│ no
▼
── CREATE PATH ──
- upload to storage with 8-byte random suffix
- INSERT document (content_hash, external_id, original filename)
- audit log: action="upload_created"
- trigger pipeline in background
- return 200, meta.idempotent_replay = false
Response shape:
{
"data": {
"id": "550e8400-…",
"filename": "invoice-001.pdf",
"status": "pending",
"file_type": "pdf",
"file_size": 184320,
"external_id": "client-system-2024-1041",
"content_hash": "a1b2…",
"created_at": "2026-06-07T10:30:00.000Z"
},
"meta": { "idempotent_replay": false },
"error": null
}
On replay, data is the existing document and meta = { idempotent_replay: true, dedup_by: "external_id" | "content_hash" }.
Repeated filenames are accepted — deduplication is by external_id / content_hash, never by filename.
force=true (testing aid): bypasses the content-hash check and creates + processes a fresh document even for identical bytes. Two consequences, by design: (1) the forced row is stored without a content hash, so it never participates in future content dedup — the original document keeps winning; (2) force cannot reuse an existing external_id (400) — send a new one or omit it. The response carries meta: { idempotent_replay: false, forced: true }. Also accepted as a JSON field on /upload/finalize. Don't use it in production ingestion — it defeats the duplicate protection.
POST /api/v2/documents/upload also accepts a single .zip container through the same multipart file field (detected by .zip extension or a ZIP MIME type). Each entry inside the archive becomes its own document with its own idempotency.
Container rules:
.zip files are rejected per-entry (nested_zip_not_supported), never unpacked.external_id on the request, each entry gets "{external_id}:{entry_filename}" as its own idempotency key.Per-entry response — the endpoint returns one item per archive entry instead of one document:
{
"data": {
"container_kind": "zip",
"container_filename": "belege_2026_06.zip",
"items": [
{ "kind": "accepted", "id": "…", "filename": "rechnung_01.pdf", "status": "pending", "rejected_reason": null },
{ "kind": "idempotent_replay", "id": "…", "filename": "rechnung_02.pdf", "status": "completed", "rejected_reason": null },
{ "kind": "rejected", "id": null, "filename": "~$rechnung_03.docx", "rejected_reason": "office_lock_file" }
]
},
"meta": { "container_kind": "zip", "total_entries": 3, "accepted": 1, "idempotent_replay": 1, "rejected": 1 }
}
Per-entry rejected_reason values:
| Reason | Meaning |
|---|---|
directory_entry | Folder entry, nothing to ingest |
nested_zip_not_supported | .zip inside the archive — flatten before uploading |
entry_too_large | Entry exceeds the per-file size limit |
unsupported_type | Extension outside the supported document types |
empty_entry | Zero-byte or unreadable entry |
office_lock_file | Microsoft Office temporary/owner file (~$…) — a stub Word/Excel writes while a document is open, never a real document |
Container-level failures (whole archive, persisted as ONE failed document row so a subscribed document.failed webhook fires once): container not readable (corrupt/not a ZIP) and too many entries.
Note: standalone (non-container) uploads of Office lock files are rejected up front with
400on both multipart/uploadand presigned/upload/init.
For files larger than the multipart /upload cap of 4 MB, use the two-step presigned flow below — the file content streams directly to Supabase Storage and never touches our API function. (Multipart /upload is capped at 4 MB because our serverless runtime rejects request bodies over 4.5 MB before the handler runs — platform limit, not application-configurable.)
The presigned flow works for any file size up to 250 MB (matches the storage bucket's file_size_limit). Files over 250 MB are not supported — contact us if you have a use case.
/api/v2/documents/upload/initRequest a signed PUT URL for the upload. Accepts JSON; no file bytes here.
Request:
{
"filename": "invoice-001.pdf",
"file_size": 12345678,
"mime_type": "application/pdf",
"external_id": "client-system-2024-1041",
"metadata": { "source": "scan-batch-12" }
}
| Field | Required | Description |
|---|---|---|
filename | yes | Original filename, preserved on the eventual documents.filename |
file_size | yes | Bytes — must be ≤ 250 MB |
mime_type | yes | Must be in the allowed list (same set as /upload) |
external_id | recommended | Stable caller id — primary idempotency key |
metadata | no | Ignored on init; passed in finalize |
Response (new):
{
"data": {
"upload_url": "https://<project>.supabase.co/storage/v1/object/upload/sign/documents/<path>?token=…",
"upload_token": "eyJ…",
"storage_path": "<org_id>/<safe>-<random>.pdf",
"expires_at": "2026-06-10T09:30:00.000Z"
},
"meta": { "idempotent_replay": false },
"error": null
}
Response (idempotent on external_id):
{
"data": { "id": "550e8400-…", "filename": "invoice-001.pdf", "status": "completed", … },
"meta": { "idempotent_replay": true, "dedup_by": "external_id" },
"error": null
}
On idempotent hit there is no upload URL — the caller must not upload again. The existing document is returned verbatim.
curl -X PUT "<upload_url>" \
--data-binary "@invoice-001.pdf" \
-H "Content-Type: application/pdf"
The signed URL is valid for 2 hours. If it expires, call /upload/init again with the same external_id and we'll issue a fresh URL (or return the existing doc if a parallel finalize already completed).
Alternatively, with the @supabase/supabase-js SDK:
await supabase.storage.from("documents").uploadToSignedUrl(storage_path, upload_token, file);
/api/v2/documents/upload/finalizeOnce the PUT succeeds, register the document.
Request:
{
"storage_path": "<from init response>",
"filename": "invoice-001.pdf",
"mime_type": "application/pdf",
"file_size": 12345678,
"external_id": "client-system-2024-1041",
"content_hash": "a1b2…",
"metadata": { "source": "scan-batch-12" }
}
| Field | Required | Description |
|---|---|---|
storage_path | yes | Verbatim from the init response |
filename | yes | Original filename to persist |
mime_type | yes | Must be in the allowed list |
file_size | yes | Bytes — must be ≤ 250 MB |
external_id | recommended | Idempotency key |
content_hash | no | SHA-256 hex of the file content — enables content-based dedup. If omitted, dedup is external_id-only |
metadata | no | Stored verbatim on documents.extracted_metadata |
Response: identical shape to /upload ({ data: documents_row, meta: { idempotent_replay, dedup_by? } }).
Errors:
| Status | When |
|---|---|
400 | missing/invalid field |
403 | storage_path not under your org folder |
413 | file_size over 250 MB |
422 | storage object not found at storage_path (upload incomplete or wrong path) |
429 | rate-limit (100 req/min, shared with /upload) |
Idempotency order: external_id → content_hash → insert. Either hit returns the existing document and does not touch the just-uploaded storage object. (Orphaned storage objects from never-finalized init calls are swept by a separate cleanup job; do not rely on this for correctness — finalize what you init.)
/api/v2/documents/upload/batchUp to 50 files per request. Each item runs the same idempotency rules independently.
Multipart fields:
| Field | Description |
|---|---|
files[] | Repeatable file field (max 50) |
external_ids[] | Optional, paired index-wise with files[] |
metadata[] | Optional, JSON string per file |
Response shape:
{
"data": [
{ "index": 0, "status": "created", "doc": { … } },
{ "index": 1, "status": "idempotent_replay", "doc": { … }, "dedup_by": "external_id" },
{ "index": 2, "status": "failed", "error": "Unsupported file type: image/heic" }
],
"meta": { "total": 3, "created": 1, "idempotent_replay": 1, "failed": 1 },
"error": null
}
One bad item never aborts the rest. Each successful item (created or replay) writes one audit row.
/api/v2/taxonomyReturns the full taxonomy (bereiche, doc_types with v1_projection, doc_subtypes with parent type, intents with v1_projection, tag_groups, tags). German labels included. The taxonomy is global today; per-org overrides will fold in transparently when shipped.
Useful for rendering dropdowns and filter pickers without hardcoding values that may grow.
/api/v2/usageReturns a usage snapshot for your organization (the org the API key belongs to). Read-only. Authenticated with your API key as a Bearer token, like every other endpoint — the org is always taken from the key, never from the request.
The period defaults to the current calendar month, month-to-date (UTC). Pass ?period=YYYY-MM to read a past month (e.g. ?period=2026-03); period_start is echoed back so the window is unambiguous. A malformed period returns 400.
api_calls.by_route for the current month comes from request telemetry that has 90-day retention. Past months are served from a durable monthly rollup, so per-route history for older months stays available beyond the 90-day window. The source field reports which: "live" (current month) or "rollup" (a past month).
# current month
curl -H "Authorization: Bearer idms_..." \
https://idms.mory.ai/api/v2/usage
# a specific past month
curl -H "Authorization: Bearer idms_..." \
"https://idms.mory.ai/api/v2/usage?period=2026-03"
Response:
{
"data": {
"period_start": "2026-06-01T00:00:00.000Z",
"source": "live",
"api_calls": {
"this_period": 1432,
"by_route": [
{ "route": "/api/v2/documents", "request_count": 980, "last_seen": "2026-06-19T17:42:11Z" },
{ "route": "/api/v2/documents/:id", "request_count": 452, "last_seen": "2026-06-19T17:40:02Z" }
]
},
"documents": { "total_stored": 21043, "ingested_this_period": 318 },
"storage": { "bytes_used": 48210334720 },
"remaining": { "rate_limit": 97, "rate_limit_reset": 1750352580 },
"limits": { "documents_max": null, "api_calls_max": null }
},
"meta": null,
"error": null
}
source is "live" for the current month (raw telemetry, 90-day retention) or "rollup" for a past month (durable monthly aggregate that outlives the 90-day window).documents.total_stored and storage.bytes_used are point-in-time (current totals), not reconstructed for a historical period. documents.ingested_this_period and api_calls.* are scoped to the requested month.remaining.rate_limit / rate_limit_reset mirror the X-RateLimit-* response headers (requests left in the current minute window, and the reset time as a Unix timestamp).limits.* are null today, meaning unlimited — placeholders for future per-org quotas.{ "data": null, "error": { … } }): 401 for a missing or invalid API key, 429 when the per-key rate limit is exceeded — see Error codes.POST /api/v2/searchSearch your organization's documents by meaning and by keyword in a single call. The query is matched two ways — a semantic leg that understands paraphrase and synonyms (so "Mietzinserhöhung" also surfaces "Anpassung des Mietzinses"), and a keyword leg for exact strings like invoice numbers, names or references. The two result sets are fused (reciprocal-rank fusion) into one ranked list, and matched_via tells you which leg(s) surfaced each hit. Read-only; the org is always taken from your API key.
Availability is per organization. If search is not enabled for your org the endpoint returns 403 — contact us to enable it.
Request (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | The natural-language query. |
mode | string | no | hybrid (default) — semantic + keyword fused. semantic — meaning only. keyword — exact-match only. |
limit | integer | no | Max results, 1–100. Default 25. |
doc_type | string | no | Restrict to a document type (same vocabulary as GET /documents). |
bereich | string | no | Restrict to a Bereich. |
intent | string | no | Restrict to an intent. |
tags | string[] | no | Restrict to documents carrying all of these tag keys. |
created_after | string (YYYY-MM-DD) | no | Only documents created on or after this date. |
created_before | string (YYYY-MM-DD) | no | Only documents created before this date. |
sender | string | no | Absender — substring match on the document's sender name. |
receiver | string | no | Empfänger — substring match on the document's receiver name. |
property | string | no | Liegenschaft — substring match on the document's property name or address. |
Response 200:
{
"data": [
{
"document_id": "6e27ff62-790a-44e6-9ed5-7d689cbc7602",
"filename": "Mietzinsanpassung_2024.pdf",
"doc_type": "letter",
"score": 0.032266,
"snippet": "…Anpassung des Mietzinses per 1. April 2024 gemäss Referenzzinssatz…",
"chunk_index": 2,
"matched_via": ["semantic", "keyword"]
}
],
"meta": { "query_id": "…", "mode": "hybrid", "took_ms": 214, "total": 1 },
"error": null
}
score is the fused relevance score (higher is better); use it for ordering, not as an absolute threshold.snippet is the passage centered on the first query-term match (≤ 280 chars, … marks truncation), with matched query terms wrapped in <mark>…</mark>. The text is HTML-escaped, so the field is safe HTML; strip the tags if you want plain text.matched_via is an array containing "semantic", "keyword", or both.meta.mode reports the mode that actually ran — a hybrid request stays available even if the semantic component is briefly unavailable, in which case it transparently returns keyword-only results and meta.mode is "keyword".Errors: 400 missing/invalid query; 401 invalid/missing API key; 403 search not enabled for your organization; 429 rate limit (100/min, shared with all API calls); 503 for an explicit mode:"semantic" request while the semantic component is temporarily unavailable (a hybrid request degrades to keyword instead).
Example:
# Hybrid (default) — meaning + keyword, fused:
curl -X POST https://idms.mory.ai/api/v2/search \
-H "Authorization: Bearer idms_..." \
-H "Content-Type: application/json" \
-d '{ "query": "Mietzinserhöhung 2024", "limit": 5 }'
# Restrict to a type and a date window:
curl -X POST https://idms.mory.ai/api/v2/search \
-H "Authorization: Bearer idms_..." \
-H "Content-Type: application/json" \
-d '{ "query": "Heizkostenabrechnung", "doc_type": "invoice", "created_after": "2024-01-01" }'
POST /api/v2/search/answerOptional (search phase 3). Runs the same hybrid search as POST /api/v2/search, then asks an LLM to synthesize a natural-language answer over the top matching excerpts, with every factual claim cited inline ([1], [2], …). sources lists only the excerpts the model actually cited — a citation number the model invents never produces a fabricated entry, and excerpts it never cites are simply omitted. Read-only; the org is always taken from your API key.
Availability is per organization, and separate from plain search — enabling POST /api/v2/search does not enable this endpoint. If answer synthesis is not enabled for your org, it returns 403 — contact us to enable it.
The LLM call runs on the same EU/CH-compliant (Switzerland North) provider the rest of the AI pipeline uses — no document text leaves the same residency boundary as classification.
Request (JSON) — same filters as POST /api/v2/search (doc_type, bereich, intent, tags, created_after, created_before, sender, receiver, property), plus:
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | The natural-language question. |
mode | string | no | hybrid (default) / semantic / keyword — same semantics as POST /api/v2/search. |
limit | integer | no | Number of context chunks fed to the LLM, 1–10. Default 5. |
Response 200:
{
"data": {
"answer": "The rent is CHF 1800 [1] and additional costs are CHF 150 [2].",
"sources": [
{ "marker": "[1]", "document_id": "6e27ff62-790a-44e6-9ed5-7d689cbc7602", "chunk_index": 0, "filename": "Mietvertrag.pdf" },
{ "marker": "[2]", "document_id": "8f31aa11-2222-4a9c-9c1e-1234567890ab", "chunk_index": 1, "filename": "Nebenkosten.pdf" }
]
},
"meta": { "query_id": "…", "mode": "hybrid", "took_ms": 640, "chunks_used": 2 },
"error": null
}
answer cites every claim inline; if the matching documents don't contain enough information, the answer says so instead of guessing.sources mirrors only the citation markers present in answer — never a fabricated reference.meta.chunks_used is 0 when no documents matched the query; in that case answer is a fixed "no information" message and no LLM call is made.Errors: 400 missing/invalid query; 401 invalid/missing API key; 403 answer synthesis not enabled for your organization; 429 rate limit (100/min, shared with all API calls); 503 the LLM provider is temporarily unavailable (or an explicit mode:"semantic" request while the semantic component is down).
Example:
curl -X POST https://idms.mory.ai/api/v2/search/answer \
-H "Authorization: Bearer idms_..." \
-H "Content-Type: application/json" \
-d '{ "query": "Wie hoch ist der Mietzins?", "limit": 3 }'
A profile is a per-org renderer applied to read responses. Pass ?profile=<name> to GET /api/v2/documents or GET /api/v2/documents/:id and the server rewrites the response shape.
Two profile types ship today:
Generic profile — JSONB config in output_profiles.fields:
{
"renamed": { "doc_type": "Belegart", "intent": "Absicht" },
"excluded": ["storage_path"],
"tag_groups_included": ["maintenance", "utility", "legal"],
"required_fields": ["Belegart", "Daten"],
"fallback_label": "Sonstiges"
}
Built-in 4-level routing profiles — for integrations whose target system uses a fixed taxonomy of folders + templates + sub-templates, the API ships routing engines that map our classification to verbatim names in the target system. Example response shape (profile keys like acme_v1 are provisioned per client by your account manager):
GET /api/v2/documents/<id>?profile=<profile_key>
{
"data": {
"id": "550e8400-…",
"modul": "Belege",
"ordner": "EK Einkauf",
"template": "EK Rechnung",
"belegart": "Default",
"direction": "eingehend",
"buchungsrelevant": false,
"rule_id": "rechnung_eingehend",
"used_fallback": false,
"ou": { "short_name": "TENANT.001", "full_name": "Tenant 001 (Property Management)" },
"status_set": "default_documents",
"status": "neu",
"required_fields": ["Subject", "Buchungsdatum", "Rechnungsdatum", "Periode"],
"fields": { "betrag": "256.74", "iban": "CH93 …" },
"contacts": [ … ]
},
"meta": {
"profile": { "id": "<uuid>", "name": "<profile_key>" },
"routing_rule": "rechnung_eingehend",
"missing_required_fields": ["Buchungsdatum", "Periode"]
},
"error": null
}
Caller-supplied overrides on a routing profile: ?direction=eingehend|ausgehend|neutral and ?buchungsrelevant=true|false let you force the routing decision without changing the document content.
Profiles are configured server-side per org via the output_profiles table (is_default controls the default when ?profile= is omitted). Without a configured profile, the API returns the raw classification row shape. Ask your account manager for the profile key provisioned for your tenant.
Webhooks are POSTed to the URL you register in Settings → Webhooks, with:
Content-Type: application/jsonX-IDMS-Event: <event-name> (e.g. document.processed)X-IDMS-Signature: <hex> — HMAC-SHA256 of the request body, hex-encoded, using the secret shown when you create the webhook[0, 60s, 300s]Node.js
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader, "hex"),
Buffer.from(expected, "hex")
);
}
Python
import hmac
import hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# constant-time compare — never use `==`
return hmac.compare_digest(signature_header, expected)
# Flask: pass request.get_data() (the RAW bytes), not request.json —
# verify(request.get_data(), request.headers.get("X-IDMS-Signature", ""), SECRET)
Important: hash the raw request body bytes. Re-serializing the parsed JSON changes whitespace / key order and breaks the comparison.
The API ships 6 event types, all in the clean root format — the delivered fields sit at the top level of the payload. (The two original events, document.processed and batch.completed, previously also carried a deprecated nested data mirror; it was removed on 2026-07-04.) New webhook subscriptions opt in to events — existing subscriptions don't auto-subscribe to the newer ones.
document.processed{
"event": "document.processed",
"timestamp": "2026-06-07T08:00:00.000Z",
"document_id": "550e8400-…",
"external_id": "client-system-2024-1041",
"filename": "invoice-001.pdf",
"doc_type": "invoice",
"doc_subtype": "service_invoice",
"tags": ["plumbing"],
"extracted_metadata": { "betrag": "256.74", "currency": "CHF" }
}
When the org has opted into the current classification model, the payload additionally carries classification_v3: { doc_type, doc_subtype, doc_bereich, intent_v3, status_lifecycle, zahlungsstatus, tags }.
For those same opted-in orgs the payload also carries a top-level extracted_contacts array (sibling of related_contacts; omitted when the document has no contacts) — the org-level deduplicated contact records for this document. The same array, same shape, same gating is returned by the document detail read (GET /api/v2/documents/{id}, with or without a profile), so polling integrators and webhook consumers see identical contact data. Each entry is the documented contact object plus three fields specific to this array:
| Field | Type | Description |
|---|---|---|
confidence | number | null | Extraction confidence for THIS contact on THIS document (0..1). A property of the extraction event — deliberately not on the stored contact / contacts endpoint. null only for links created before 2026-06-12 or via manual PATCH. |
function | string | null | Person's role/job title as stated in the document (e.g. "Geschäftsführerin") |
relationships | [{ contact_id, relationship_type }] | Explicit pairing signal. contact_id references a SIBLING entry in the same array; relationship_type ∈ employee_of / represents / related_to. Emitted only when the document states the link — never inferred from roles or array order. |
external_id carries the caller-supplied id when the matched contact was created/updated through the API with one; contacts created purely by extraction have external_id: null.
batch.completed{
"event": "batch.completed",
"timestamp": "2026-06-07T08:00:00.000Z",
"batch_id": "b_001",
"total_files": 10,
"processed_files": 10,
"status": "completed",
"documents": [ { "id": "...", "status": "completed" }, … ]
}
document.failed (new — clean root)Fires after the pipeline exhausts its retry budget and parks the document at status="failed".
{
"event": "document.failed",
"timestamp": "2026-06-07T08:00:00.000Z",
"document_id": "550e8400-…",
"filename": "broken.pdf",
"external_id": "client-system-2024-1041",
"error_stage": "ai",
"error_message": "AI processing timed out after 60s"
}
error_stage ∈ download / extract / ai / persist / unknown. error_message is sanitized: filesystem paths → [path], URLs → [url], stack frames stripped, 500-char cap.
Stable error_message wordings (safe to match on exactly — they will not change without a changelog entry):
| Condition | Exact string |
|---|---|
| Corrupt / invalid Office file | File is corrupt or not a valid Office document (.docx/.xlsx/.pptx). |
| Password-protected file | File is password-protected and cannot be processed. Remove the password protection and upload it again. |
| Content declined by AI processing | The AI service declined to process this document's content. |
Note: a pure image / scan with no recognizable text does not fail — the document completes (document.processed) with extracted_metadata.empty_content = true; treat that flag as the "no text" signal.
classification.changed (new — clean root)Fires when a PATCH endpoint changes a classification axis. One event per axis touched in a single PATCH (cleaner downstream parsing).
{
"event": "classification.changed",
"timestamp": "2026-06-07T08:01:00.000Z",
"document_id": "550e8400-…",
"field": "doc_type",
"old": "invoice",
"new": "rechnung",
"actor": { "kind": "api_key", "id": "k_xyz" }
}
field ∈ doc_type / doc_subtype / doc_bereich / intent_v3 / status_lifecycle / zahlungsstatus / tags.
tag.suggested (new — clean root)Fires when the AI proposes a tag that isn't in the controlled taxonomy.
{
"event": "tag.suggested",
"timestamp": "2026-06-07T08:02:00.000Z",
"document_id": "550e8400-…",
"suggested_tag": "solar_panel_maintenance",
"confidence": 0.84
}
Dedup is per (document_id, suggested_tag) — re-processing the same doc with the same suggestion never re-fires.
usage.threshold.reached (new — clean root)Fires when your organization's API-call count for the current calendar month first crosses the alert threshold configured in Settings. Fires at most once per calendar month — the first crossing in a month sends one event; further calls that month do not re-fire. The counter resets at the start of each month.
{
"event": "usage.threshold.reached",
"timestamp": "2026-06-19T17:45:00.000Z",
"organization_id": "550e8400-…",
"period_start": "2026-06-01T00:00:00.000Z",
"threshold": 10000,
"api_calls": 10034
}
threshold is the configured value; api_calls is the month-to-date count at the moment it crossed (always >= threshold). Use it to get ahead of quota / billing surprises rather than discovering them after the fact.
Existing webhook subscriptions list specific event types in their events column. The newer events (document.failed, classification.changed, tag.suggested, usage.threshold.reached) are not added to existing subscriptions automatically — pick them in Settings → Webhooks when you want them. This guarantees existing integrations never receive surprise events without explicit opt-in.
POST /api/v2/feedbackReport classification feedback from your side: a field your users corrected, or an AI draft your users deleted. Feedback is intake only — it never changes the document or its classification. We use it to measure and improve extraction quality.
Request (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
document_id | uuid | yes | The iDMS document id (from document.processed or the read API). Must belong to your organization. |
event_type | string | yes | classification.changed — your user corrected a field. ai_rejected — your user deleted the AI draft entirely (negative signal, no field detail needed). |
field_path | string ≤ 200 | for classification.changed | Which field was corrected, e.g. doc_type, extracted_metadata.total_amount. Free-form path — use your own canonical naming consistently. |
old_value | string ≤ 2000, nullable | no | The value before the correction (as you received it). |
new_value | string ≤ 2000, nullable | no | The corrected value. |
Response 200:
{ "data": { "id": "<feedback-id>", "received": true }, "meta": null, "error": null }
Errors: 400 validation details in error; 401 invalid/missing API key; 404 document not found in your organization; 429 rate limit (100/min, shared with all API calls).
Example:
curl -X POST https://idms.mory.ai/api/v2/feedback \
-H "Authorization: Bearer idms_..." \
-H "Content-Type: application/json" \
-d '{
"document_id": "6e27ff62-790a-44e6-9ed5-7d689cbc7602",
"event_type": "classification.changed",
"field_path": "doc_type",
"old_value": "invoice",
"new_value": "contract"
}'
# Your user deleted the AI draft — pure negative signal:
curl -X POST https://idms.mory.ai/api/v2/feedback \
-H "Authorization: Bearer idms_..." \
-H "Content-Type: application/json" \
-d '{ "document_id": "6e27ff62-790a-44e6-9ed5-7d689cbc7602", "event_type": "ai_rejected" }'
All errors follow the standard envelope:
{ "data": null, "meta": null, "error": "Human-readable message" }
For locked documents, the body also carries a machine-readable code:
{ "data": null, "meta": null, "error": "Document is locked (status_lifecycle=abgeschlossen)", "code": "DOCUMENT_LOCKED" }
| Status | Meaning | Common causes |
|---|---|---|
200 | Success | — |
400 | Bad request | Invalid JSON; missing required field; bad enum value; DOCUMENT_LOCKED; out-of-org contact_id; bulk over 100 items; Office lock file (~$…) sent as a standalone upload |
401 | Unauthorized | Missing or invalid API key |
404 | Not found | Document not in caller's organization, or no document with that id (no 403 ever — no RLS leak) |
413 | Payload too large | Single file over 4 MB (multipart /upload) or over 250 MB (presigned /upload/init). Multipart hard cap is the 4.5 MB serverless runtime body limit; use the presigned flow for larger files. |
422 | Unprocessable entity | Unknown taxonomy key (doc_type, doc_subtype, intent, tag) |
429 | Rate limited | > 100 requests/minute on this key — wait until X-RateLimit-Reset |
500 | Server error | Database error or unexpected internal failure. Retry; contact support if persistent. |
The API never returns 403 — cross-org references return 400 (POST/DELETE tag, contact link) or 404 (document lookup) so the response is indistinguishable from a missing resource.
Most likely your key belongs to a different organization than the document. The API returns 404 instead of 403 for cross-org access so an attacker can't probe for the existence of foreign documents. Verify by listing /api/v2/documents — if your document isn't in the list, it's in a different org.
400 DOCUMENT_LOCKEDThe document's status_lifecycle is one of abgeschlossen / finalized / deleted. These are terminal write-states. To edit, either:
in_bearbeitung (also a PATCH), orexternal_id=<new-id>.abgeschlossen is meant to be set when a human has reviewed and signed off — toggling it back means you're explicitly re-opening the review.
idempotent_replay: true but I sent a brand-new file"You hit one of the two idempotency keys. Check:
dedup_by: "external_id" — you sent the same external_id twice. The API considers that intentional; the caller's id is the trust boundary.dedup_by: "content_hash" — the SHA-256 of the file content matches an existing document in your org. If you uploaded the file before (possibly to a different filename), the API returns the existing record. To force a new document, modify any byte of the file or send a fresh external_id.meta.missing_required_fields is non-empty on a routing-profile responseRouting profiles look up per-template required fields (Pflichtfelder) from a per-client configuration. When extracted_metadata is missing one of these fields, the response succeeds (200) but lists the missing field names in meta.missing_required_fields. This is advisory only — caller can fill them via PATCH /api/v2/documents/:id/metadata and try the same GET again to confirm.
The most common cause is hashing the re-serialized JSON body instead of the raw bytes. Example anti-pattern in Express:
// WRONG — req.body is the parsed object, not the raw bytes
const sig = hmac(JSON.stringify(req.body));
// RIGHT — preserve the raw bytes
app.post("/idms-webhook", express.raw({ type: "*/*" }), (req, res) => {
const sig = hmac(req.body); // req.body is a Buffer here
});
For frameworks where you don't control the raw bytes, capture them in a middleware before JSON parsing.
tag.suggested subscription but no events arrive"Two checks:
POST /api/v2/search/answer (RAG synthesized answer, optional)New endpoint (search phase 3). Synthesizes a natural-language answer over the top hybrid-search matches, with every claim cited inline ([1], [2], …); sources lists only excerpts the model actually cited. Opt-in per organization, separate from plain search's opt-in — enabling POST /api/v2/search does not enable this endpoint. LLM calls run on the same EU/CH-compliant (Switzerland North) provider the rest of the AI pipeline uses. See Search.
data mirror removeddocument.processed and batch.completed previously duplicated their fields in a nested data object for backward compatibility. That mirror is removed — all six webhook events now ship the clean root format only (fields at the top level). Receivers must read payload.<field>, not payload.data.<field>.
POST /api/v2/search gains three optional filters — sender (Absender), receiver (Empfänger), property (Liegenschaft) — substring-matched on the document's entity names. Snippets are now centered on the first query-term match and wrap matched terms in <mark>…</mark> (HTML-escaped). See Search.
POST /api/v2/search (hybrid semantic + keyword)New endpoint. Meaning-based and keyword search fused into one ranked list, with matched_via per hit and the same doc_type / bereich / intent / tags / date filters as the documents list. Available per organization (403 until enabled). See Search.
usage.threshold.reached webhookGET /api/v2/usage now accepts ?period=YYYY-MM to read a past month. Current-month data is live (90-day-retention telemetry); past months are served from a durable monthly rollup that outlives the 90-day window, so older per-route history stays queryable. A new source field reports "live" or "rollup". Default behavior (no period) is unchanged — still the current month, month-to-date. A malformed period returns 400.usage.threshold.reached — fires once per calendar month when your org's API-call count crosses a configured alert threshold. Clean root payload { event, timestamp, organization_id, period_start, threshold, api_calls }. Like the other opt-in events, existing subscriptions are not auto-subscribed — enable it in Settings → Webhooks./api/v2/usage snapshot endpointGET /api/v2/usage returns a month-to-date usage snapshot for your organization: api_calls (total this_period plus a per-route by_route breakdown), documents (total_stored, ingested_this_period), storage.bytes_used, the current rate-limit remaining / rate_limit_reset (mirroring the X-RateLimit-* headers), and limits.* (null = unlimited until per-org quotas land). The organization is taken from the API key, so there are no path or query parameters.api_calls.by_route is sourced from request telemetry with 90-day retention, so the per-route history reaches back at most 90 days. Additive — nothing to change in existing integrations.extracted_metadata describing the timeframe a document covers:
period_from / period_to — the reporting period a document looks back on (tax documents, financial reports, bank statements, HR/payroll).valid_from — the start of a validity window; pairs with the existing valid_until (insurance, certificates, financing documents, contracts).DD.MM.YYYY strings. Populated only for the relevant document type and only when the document states the dates explicitly (a bare fiscal year like "2024" expands to 01.01.2024–31.12.2024); null/absent otherwise. A document carries at most one of the two pairs.GET /api/v2/documents, GET /api/v2/documents/:id) inside extracted_metadata, just like the other metadata fields. Additive only — no client change required; existing integrations are unaffected./api/v1/* API has been removed; v2 is now the only public API surface. v2 is a field-superset of v1 (every v1 field is still present, plus the current-model fields), so existing integrations migrate by swapping the base path /api/v1 → /api/v2 and reading document_tags[].tag_key (the redundant tag_value is dropped — it always equalled tag_key). The former v1-only batches and sync/documents endpoints are retired — use GET /api/v2/documents?updated_since=<ts> + the meta.server_time cursor for delta sync.GET /api/v1/batches, GET /api/v1/batches/:id, and GET /api/v1/sync/documents so this page is the single complete API reference. No behavior change — these endpoints already existed on the /api/v1/ path; they are now described here (with a note that the /api/v1/ path is what's live today). (Superseded: the entire /api/v1/* surface was removed — see the latest changelog entry. Use GET /api/v2/documents?updated_since= + meta.server_time for delta sync.)GET /api/v2/documents now supports betrag_min / betrag_max (number — filter on extracted_metadata.total_amount) and faellig_after / faellig_before (ISO date YYYY-MM-DD — filter on extracted_metadata.due_date). All optional, combinable with the existing filters; amount/date parsing is server-side and calendar-invalid stored dates are treated as no-match (never error). Previously documented as not-implemented.doc_type can now also be plan, land_register_extract, or authority_decision — non-image documents (building/floor plans and drawings, land-register/cadastral extracts, authority decisions and permits) that were previously dumped into other. Additive values in the existing open vocabulary; doc_type is a string, not a fixed enum — v1 consumers that whitelist types should add these if they want to surface them, otherwise they behave like any other type.doc_subtype is now strict: it is always either a known subtype for its doc_type or null. Previously the model could emit free-text or German variants that leaked into the field; those now collapse to null. No action needed — already-valid subtypes are unchanged; only invented/foreign values stop appearing.photo doc_typedoc_type can now be photo — image files (jpg/png/…) the classifier cannot assign to a real document type are typed photo instead of other. Filterable via ?doc_type=photo. Additive value in the existing vocabulary; v1 consumers that whitelist types should add photo if they want to surface it (otherwise it behaves like any other type).extracted_metadata._ai_possible_duplicate: true is now set on documents whose filename carries a copy marker ("- Kopie", "(1)", "- Copy"). Non-destructive — the document is still processed normally; the flag only surfaces likely duplicates for review. Absent when not a copy. Additive.extracted_metadata.vat_rate — the VAT/MwSt rate as a percentage number without the % sign (e.g. "8.1", "2.6", "3.8"), nullable. Extracted ONLY when the document explicitly states the rate; never inferred. Appears in the read API and the document.processed webhook alongside the other amounts.extracted_contacts on the detail read (v3 orgs)GET /api/v2/documents/{id} now returns the same top-level extracted_contacts array the document.processed webhook delivers (same builder, same v3-org gating, same one-entry-per-contact + relationships semantics). Applies to the raw shape and to every output profile.external_id + updated_since filtersGET /api/v2/documents?external_id=<id> — exact lookup by your external_id.GET /api/v2/documents?updated_since=<ISO8601> — documents with updated_at after the
timestamp, ordered updated_at ascending (delta sync); malformed timestamp → 400.external_id for idempotent
lookups and updated_since for delta-sync polling.meta now also carries server_time (the server's query-time timestamp) —
save it and pass it as the next updated_since for incremental polling._-prefixed markers are no longer exposedextracted_metadata._ai_possible_duplicate (and any other internal _-prefixed bookkeeping key) is no longer included in API responses or webhook payloads. It remains an internal review marker only. This reverses the 2026-06-13 note above that surfaced _ai_possible_duplicate to clients.extracted_metadata.booking_date (format DD.MM.YYYY, nullable) — extracted ONLY when the document explicitly labels a booking date ("Buchungsdatum", "gebucht am"); never inferred from the issue or due date. Appears in the read API and the document.processed webhook payload alongside date_issued.error_message: File is password-protected and cannot be processed. Remove the password protection and upload it again. Previously an encrypted PDF could complete silently with empty content.document.failed section now lists all stable error_message wordings integrators may match on exactly; they will not change without a changelog entry.POST /api/v2/feedback — report a field your users corrected (event_type: "classification.changed" with field_path, old_value, new_value) or an AI draft your users deleted (event_type: "ai_rejected", no field detail needed).404 for documents outside your organization.Add-only. Newest entries on top. Older entries are preserved verbatim.
force=true testing flagPOST /upload (multipart field) and POST /upload/finalize (JSON field) accept force: true to bypass content-hash idempotency and re-process identical bytes as a new document. Forced rows store no content hash (excluded from future dedup); reusing an existing external_id with force returns 400. Response meta gains forced: true. Built for integration test loops — production ingestion should not use it.extracted_contacts on document.processed (v3 orgs)document.processed for v3-opt-in organizations now carries a top-level extracted_contacts array — the document's deduplicated contact records with per-contact confidence (0..1), the new function field (person's role/job title) and an explicit relationships pairing signal referencing sibling entries. See Webhooks.function (e.g. "Geschäftsführerin"); also returned on the contacts endpoint./openapi-v2.yaml. Generated from the route handlers; validated with Redocly. The markdown reference stays authoritative for semantics.contact_id values of merged duplicates no longer resolve. Treat document_id as the stable key and re-fetch contacts instead of caching contact_id long-term (now documented on the contacts endpoint). Document ids, payload structure and webhooks are unchanged.~$… stubs) are now rejected up front: 400 on multipart /upload and presigned /upload/init, and per-entry rejected_reason: "office_lock_file" inside ZIP containers.error_message "File is corrupt or not a valid Office document (.docx/.xlsx/.pptx)." instead of a raw parser error — visible on the document row and in the document.failed webhook payload.POST /upload/init + POST /upload/finalize flow — file bytes stream directly to storage, lifting the per-file limit from the multipart 4 MB cap to 250 MB. Multipart /upload now returns a descriptive 413 pointing at the presigned flow.date_of_birth), the document-level entity fields (sender / receiver / related_contacts / property / unit / equipment), and the ZIP container upload contract (entry rules, per-entry rejected_reason values) are now fully documented. No behavior change._v3 field-name suffix_v3 suffix on intent_v3, document_classification_v3, and v1_projection is a stable contract identifier, not an API version. A field tagged _v3 belongs to the current classification model; a field tagged v1 belongs to the legacy taxonomy the v1 API surfaces unchanged.document_classification_v3 object is null on v1 orgs and populated when opted in.document_classification_v3 was null. They assumed it was a bug, not a per-org config. The docs now state this up-front so integrators don't lose time chasing a non-bug.POST /api/v2/documents/upload/init returns a Supabase Storage signed PUT URL + the bucket-scoped storage_path + a 2-hour expiry. JSON body, no file bytes.POST /api/v2/documents/upload/finalize registers the document after the caller has PUT the file to the signed URL: idempotency (external_id → content_hash) → storage object existence check → documents row + batch + audit log + pipeline trigger./upload endpoint unusable for real-world property-management documents. The presigned flow streams the file content directly to Supabase Storage and never touches our API function, lifting the per-file cap to the storage bucket's file_size_limit (now 250 MB per migration 056).Authorization: Bearer idms_… key as the rest of v2, same 100 req/min rate-limit shared with /upload, same { data, meta, error } envelope.upload_url is a plain signed PUT — curl -X PUT --data-binary @file … works. Callers using @supabase/supabase-js can also use uploadToSignedUrl(storage_path, upload_token, file).GET /api/v2/documents/:id/download returns a short-lived Supabase Storage signed URL for the document's original bytes. Client follows the URL directly — bytes do not travel through the API function. Default TTL 3600 s, clamped to [60, 86400] via ?ttl=.IDMS_API_KEY, 100 req/min/key, { data, meta, error }).id and organization_id; a cross-org hit returns 404 (not 403) so existence is never disclosed.GET /api/v2/documents/:id.doc_bereiche, doc_types, doc_subtypes, intents, tag_groups, tags) now carries label_en alongside the existing label_de. Backfilled for all 237 taxonomy entities (8 + 31 + 121 + 13 + 10 + 54). Schema change applied via migration 052; data backfill via migration 053. Strictly additive: a v2 consumer that only reads label_de is unaffected.GET /api/v2/taxonomy now returns label_en on every row in bereiche, doc_types, doc_subtypes, intents, tag_groups, and tags. Null in the response means the row pre-dates the backfill — currently nothing is null in prod, but the field is nullable on the wire so a future taxonomy migration can introduce a new row without immediately needing the translation.Label (EN) column. The new German enum glossary subsection there documents which enum values stay German on the wire (offen, bezahlt, neu, abgeschlossen, eingehend, ausgehend, absender, empfaenger, …) and what each one means. Those values are contract identifiers and will not be renamed.bezüge, Pflichtfelder, Empfänger-Regel, Modul/Ordner/Vorlage/Belegart) now lead with the English term and keep the German in parentheses at first mention.org_config.classification_version ∈ (v1, v3), default v1 for every existing org (the value v3 here is a model-version literal — the API itself stays at v2). Flip via INSERT INTO org_config … ON CONFLICT … DO UPDATE SET classification_version = 'v3'.document.processed so the payload can carry classification_v3 = { doc_type, doc_subtype, doc_bereich, intent_v3, status_lifecycle, zahlungsstatus, tags[] } ADDITIVELY (v1 fields untouched).UPDATE org_config SET classification_version = 'v1' WHERE … reverts behavior on the next processed document. Persisted rows in document_classification_v3 are preserved.tsx scripts/reclassify-org.ts --org <uuid> --dry-run (default) or --apply (writes). Rate-limited (default 1.5 s between docs). Refuses to run when the target org is still on v1.document.failed — fires after the pipeline exhausts retries. Payload (clean root, no deprecated data wrapper): { event, timestamp, document_id, filename, external_id, error_stage, error_message }. error_message is sanitized (paths, URLs, stack frames stripped, 500-char cap).classification.changed — one event per axis touched in a PATCH /api/v2/documents/:id. Payload: { event, timestamp, document_id, field, old, new, actor: { kind, id } }.tag.suggested — fires when the AI proposes a tag outside the controlled taxonomy. Dedup per (document_id, tag) via extracted_metadata._tag_suggested_fired. Payload: { event, timestamp, document_id, suggested_tag, confidence }.document.processed and batch.completed — ship the clean root format; the deprecated nested data wrapper on the two original events was removed 2026-07-04.POST /api/v2/documents/upload — single file, multipart. Idempotency via external_id first, then SHA-256 content_hash. Same content uploaded twice in the same org → meta.idempotent_replay = true + the existing document; no second storage object, no second pipeline trigger.POST /api/v2/documents/upload/batch — up to 50 files per request. Per-item result { index, status: "created" | "idempotent_replay" | "failed", doc?, error?, dedup_by? }. One bad item never aborts the rest.20260603105516.pdf) repeat by the thousands; v2 stores each accepted upload at ${orgId}/v2/${stem}-${random8hex}${ext} so storage collisions are impossible. The caller's original filename is preserved in documents.filename.documents.content_hash (nullable) + partial UNIQUE on (org, content_hash).GET /api/v2/documents/:id?profile=<profile_key> — runs the configured 4-level routing engine for that client: Modul (module) → Ordner (folder) → Vorlage (template) → Belegart (document kind) with direction (inbound/outbound resolved via a receiver rule — Empfänger-Regel) and buchungsrelevanz (posting-relevance) axes, OU resolution from the per-tenant org tree, per-template status set + required-field (Pflichtfeld) validation. Returns { modul, ordner, template, belegart, direction, buchungsrelevant, ou, status_set, status, required_fields, missing_required_fields, ... } with meta.routing_rule + meta.missing_required_fields. Profile keys are provisioned per client by your account manager.taxonomy_maps (40 routing rows for a typical client). Migration 049 extends taxonomy_maps with direction, buchungsrelevant, and per-client routing columns (target Modul / Ordner / Template / Sub-template).output_profiles.fields JSONB carries { renamed, excluded, included, tag_groups_included, required_fields, fallback_label }.PATCH /api/v2/documents/:id — atomic correction of doc_type / doc_subtype / doc_bereich / intent / status_lifecycle / zahlungsstatus + tags + optional reason.POST /api/v2/documents/:id/tags and DELETE /api/v2/documents/:id/tags — idempotent tag UPSERT/DELETE with confidence = 1 sentinel marking manual overrides.PATCH /api/v2/documents — bulk (max 100 items, per-item result, one bad item does not abort the rest).document_audit_log row with prev/next snapshots and a diff (fields_touched or added/removed).document_tags_v3.confidence = 1 + extracted_metadata._manually_corrected_fields[] + _manually_corrected_at so a future re-classifier can detect and preserve manual edits.GET /api/v2/documents — list with current-model filtering (doc_type, subtype, intent, status, zahlungsstatus, bereich, repeatable tag).GET /api/v2/documents/:id — full document with the current classification record, structured metadata, related contacts, and linked entities (Bezüge — property/unit/equipment).GET /api/v2/documents/:id/contacts — extracted Person/Firma contacts with role (absender/empfaenger/cc), normalized across all docs in the org.PATCH /api/v2/documents/:id/classification — correct doc_type, doc_subtype, intent, status_lifecycle, zahlungsstatus, and tag set.PATCH /api/v2/documents/:id/metadata — MERGE update of the current-model extracted_metadata (null deletes a key).PATCH /api/v2/documents/:id/contacts — REPLACE the document's contact links (anti-spoof org check on every contact).GET /api/v2/taxonomy — full taxonomy (8 bereiche, 31 doc_types, 121 subtypes, 13 intents, 10 tag_groups, 54 tags) with German labels and v1 projection.document_classification_v3 (doc_bereich, doc_subtype, intent_v3, status_lifecycle, zahlungsstatus) plus document_tags_v3.status_lifecycle ∈ {abgeschlossen, finalized, deleted} return 400 DOCUMENT_LOCKED. Reads unaffected.v1_projection so clients can translate current-model values back to v1 vocabulary for legacy integrations./api/v1/*.Dashboard file upload no longer silently overwrites files with the same name in a single drag-drop batch. Duplicate names are auto-suffixed (scan.pdf → scan (2).pdf) and surfaced in the upload toast.
New field signature on documents (list, detail, sync, and the document.processed webhook): { signed, signed_by?, signed_at?, confidence?, raw? }. Auto-detected from text (mainly for contracts) and overridable in the UI. Backward-compatible additive change.
Added JavaScript examples, rate limit headers, batch.completed webhook payload, error response bodies, pagination guide, API scope notes.
New fields: doc_type, doc_subtype, doc_intent, sender, receiver, property, unit, equipment. Structured tag taxonomy (59 tags, 8 groups — v1 vocabulary). Old classification field kept for backward compatibility.
Added POST /api/v1/documents/upload and GET /api/v1/sync/documents endpoints. Client webhook support via environment variables.
Documents, batches, tags, API key auth, rate limiting, per-org webhooks.