Documentation · Maqaas v1.0.0
Maqaas Vendor Invoice Decision API — v1
For engineers integrating Maqaas into a procurement or AP product.
Maqaas takes an inbound vendor invoice — a PDF, some text, or facts you already hold — and returns an auditable operational decision: process it, review it, hold it, escalate it, or ask the vendor for more information.
Maqaas never executes payment and never contacts your vendor. It returns a recommendation and the evidence behind it. Your system decides what to do.
Machine-readable spec: maqaas-v1.yaml.
Base URL: https://api.maqaas.com
1. Endpoints
| Method | Path | Use when |
|---|---|---|
POST | /v1/invoice/decision | You have invoice text, an email body, or already-extracted facts |
POST | /v1/invoice/decision/document | You have a PDF or text file |
GET | /health | Liveness. No auth. Touches nothing. |
GET | /ready | Readiness — checks the database. No auth. 503 when the store is unreachable. |
Both decision endpoints return the same response contract. Ingestion changes the input transport, never the meaning of the result.
2. Authentication
Authorization: Bearer <your-api-key>
A single alternative header carries the same credential, for clients that
cannot set Authorization:
X-Api-Key: <your-api-key>
Authorization: Bearer is the primary form. If both are supplied the Bearer
value is used and X-Api-Key is ignored — they are not combined, and the
request does not fail for carrying two.
Missing or invalid keys return 401 UNAUTHORIZED. A key that is valid but whose
account is not entitled returns 402 instead — see §11. Keys are per-tenant;
all usage is metered against the tenant that owns the key.
3. Rate limiting
Requests are limited per tenant, currently 60 per minute. The limit is
per tenant, not per key: issuing a second key does not raise it, because every
key for a tenant shares the same window. Exceeding it returns
429 RATE_LIMITED with a Retry-After header (seconds). This is the one error
class where an identical retry is expected to succeed — retryable: true.
4. Request
Supply at least one of document, message.body_text, or extracted_facts.
{
"client_reference": "ap-invoice-99312",
"message": { "from": "ar@acme-supplies.com", "subject": "Invoice INV-2026-0412" },
"document": { "filename": "invoice.pdf", "media_type": "text/plain", "text": "..." },
"po_context": { "po_number": "PO-500", "total": { "amount": 41200, "currency": "GBP" }, "status": "open" },
"vendor_context": { "vendor_id": "V-100", "known_domains": ["acme-supplies.com"],
"trusted_bank_details": { "sort_code": "20-00-00", "account_number_last4": "9021" },
"known_invoice_numbers": ["INV-2026-0388"] },
"rules": { "allowed_variance_pct": 0.05, "require_po": true, "min_field_confidence": 0.8 }
}
extracted_facts lets you skip extraction entirely and run only the
deterministic engine — no model call, no extraction cost, fully reproducible.
Useful for replaying a decision after changing policy.
Facts you supply may be partial. Only document_type is required: it selects
real safety behaviour, so it is never defaulted. Send the fields you hold and
omit the rest — you do not have to pad the payload with null envelopes.
- Omitted fields are materialised internally as
{ "value": null, "confidence": 0, "source_verified": null }, so no rule ever sees an undefined field and the response always carries the complete set. classification_confidencedefaults to1when omitted: on this path you are stating the document type, not estimating it.- Omission is not
missing_fields. That list means the extractor looked and could not find the value. A field you simply do not use is not added to it, and produces noMISSING_FIELDwarning. - Relaxing presence does not relax validation. Any field you do send is
validated exactly as before — a malformed
invoice_total, or aconfidenceoutside[0, 1], still fails with400 INVALID_INPUT.
Maqaas runs no extraction on this path, so it verifies nothing: see
CALLER_ASSERTED_VERIFICATION in §10.
rules is your policy. The engine has no hidden thresholds; every number a
rule compares against comes from here or from a documented default.
client_reference is echoed back verbatim. It is a correlation key, not
an idempotency key. For retry safety send the Idempotency-Key header — see §12.
Document endpoint
{
"document": { "filename": "invoice.pdf", "media_type": "application/pdf", "content_base64": "JVBERi0x..." },
"po_context": { "...": "..." }
}
Limits: 10 MB decoded, 30 pages, application/pdf or text/plain.
A scanned PDF with no text layer returns OCR_REQUIRED_NOT_AVAILABLE rather
than an empty result — a scan must never look like a blank but valid invoice.
5. Response
{
"request_id": "req_9f2c41a8b0e34d7c9a15",
"client_reference": "ap-invoice-99312",
"versions": { "api": "v1", "schema": "2026-09-06.v3", "rules": "1.3.0",
"engine": "1.0.0", "extraction": "2.0.0" },
"decision": "HOLD_FOR_REVIEW",
"needs_review": true,
"recommended_route": "accounts_payable_manager",
"decision_confidence": 1,
"confidence_basis": "weakest critical signal: invoice_number=1.00",
"document_type": "invoice",
"extracted_facts": { "...": "..." },
"rules_fired": [ "..." ],
"rules_evaluated": [ "..." ],
"reasons": ["Payment details differ from the trusted vendor record (account_number_last4)."],
"warnings_structured": [ "..." ],
"extraction": { "mode": "model", "model_calls": 1, "latency_ms": 812 },
"processed_at": "2026-09-08T10:14:02.881Z"
}
6. Decisions
| Decision | Severity | Route | Meaning |
|---|---|---|---|
PROCESS | 0 | — | No evaluated rule objected. Check warnings_structured for RULE_NOT_EVALUATED before treating it as fully checked. |
REVIEW | 40 | accounts_payable_clerk | Something needs a human look |
REQUEST_MORE_INFORMATION | 50 | accounts_payable_clerk | Missing input, e.g. no PO reference |
HOLD_FOR_REVIEW | 70 | accounts_payable_manager | Payment-risk signal, e.g. changed bank details |
ESCALATE | 85 | finance_controller | Above your approval threshold |
BLOCK | 100 | vendor_risk | Reserved — see below |
When several rules fire, the highest severity wins. needs_review is simply
decision !== "PROCESS".
Two of these have preconditions worth knowing before you build a switch:
ESCALATEis produced only byAPPROVAL_THRESHOLD, which is skipped unless you sendrules.approval_threshold. Without one, it cannot occur.BLOCKis reserved and is not currently emitted by any rule. It is part of the published vocabulary so that adding it later is additive rather than a breaking change. Handle it defensively — treat an unknown or higher severity as “do not process” — but do not expect it today.
7. Confidence and verification semantics
Read this section before building on the numbers.
Every extracted value carries three distinct signals, kept separate on purpose:
"invoice_number": {
"value": "INV-2026-0412",
"model_confidence": 0.95,
"source_verified": true,
"confidence": 0.95,
"verification_reason": "exact",
"source": { "locator": "attachment:invoice.pdf", "snippet": "Invoice No. INV-2026-0412" }
}
When you supply
extracted_factsyourself, Maqaas runs no extraction, so it verifies nothing. Anysource_verifiedandmodel_confidenceyou send are echoed back as you sent them — they are your assertions, not a Maqaas check.extraction.modeiscaller_suppliedin that case, and every field claimingsource_verified: truealso produces aCALLER_ASSERTED_VERIFICATIONwarning. Do not read those values as independent confirmation.
model_confidence — not a calibrated probability
This is the model’s raw self-assessment, preserved exactly as returned. It has not been statistically calibrated against outcomes. A 0.95 here does not mean “correct 95% of the time”. Do not use it as a probability, a threshold for auto-payment, or an accuracy claim. It is kept in the response so you can calibrate it against your own outcomes over time.
model_confidence is optional: it is present only when Maqaas ran
extraction. On the caller-supplied path there is no model reading to report, and
Maqaas will not copy your confidence into it — that would misrepresent your
number as a model self-assessment. Treat the key as possibly absent.
source_verified — deterministic, and tri-state
| Value | Meaning |
|---|---|
true | Maqaas checked: the cited snippet exists in the document and contains this value. On the caller-supplied path this is your own claim, echoed. |
false | The check ran and failed |
null | No snippet was cited, so nothing could be checked |
null is not a soft false. It means “unchecked”.
confidence — the effective trust score
model_confidence after deterministic evidence adjustments. This is the number
the rule engine consumes. When verification fails, it is capped at 0.50 —
below the default min_field_confidence of 0.8 — so an unverifiable value routes
to a human instead of processing.
min_field_confidence must be greater than 0.5 and at most 1. A value at
or below 0.5 is rejected with 400 INVALID_INPUT: a challenged field is capped
at exactly 0.50, so such a floor would let it pass the check and silently
defeat the guard above.
verification_reason
| Reason | source_verified | Meaning |
|---|---|---|
exact / normalized | true | Snippet found, value present |
no_snippet / no_source / snippet_too_short | null | Nothing to check against |
not_found | false | Cited snippet is not in the document |
value_not_supported | false | Snippet is real but does not contain the value |
source_encoding_degraded | false | An unreadable glyph sits inside the identifier |
identifier_contains_placeholder | false | The identifier contains ? or a similar unknown marker |
Maqaas never repairs a value. An identifier reported as QK27WMBD-00?9 is
returned exactly as read, flagged, and routed to review — a guessed identifier is
worse than an uncertain one.
8. Extracted facts
document_type is one of invoice, credit_note, statement,
purchase_order, remittance, other, unknown.
Fields, each wrapped in the envelope above: vendor_name, vendor_id,
vat_number, company_registration_number, invoice_number, invoice_date,
invoice_total, tax_total, po_number, bank_details.
value: null means not found. Maqaas does not invent business values.
vendor_id is your buyer/procurement/ERP-side identifier for the supplier,
supported by explicit supplier/vendor-oriented evidence in the document (for
example “Vendor ID”, “Vendor Code”, “Supplier Number”). Generic account, customer,
client, bank-account, reference or unlabelled identifiers do not qualify merely
because they appear on the invoice — “Account”, “Account Number”, “Customer
Account” and “Client ID” are not vendor identifiers. A VAT number or company
registration number is never substituted into it. When the evidence is
insufficient or ambiguous, vendor_id is null.
Known gap: this is the field contract. The model-extraction path has not yet been
aligned with it, so an extracted vendor_id can still come from an unqualified
account label; that alignment is a separate, measured change. No decision rule
compares the vendor_id value itself; only UNVERIFIED_IDENTIFIER reads whether
its citation could be verified.
Bank details
Only partial identifiers are extracted, stored, or returned:
{ "bank_name": "Barclays", "sort_code": "20-11-33",
"account_number_last4": "5678", "iban_last4": "5555", "swift_bic": "BUKBGB22" }
Full account numbers and IBANs are never returned. There is no beneficiary-name field. Two accounts sharing their last four digits are indistinguishable to the bank-change rule — a known limitation, not an oversight.
9. Rules
Every rule result is auditable:
{
"rule_id": "INVOICE_PO_VARIANCE",
"rule_version": "1.0.0",
"status": "fired",
"outcome": "fired",
"decision": "REVIEW",
"severity": 40,
"fields": ["invoice_total"],
"message": "Invoice total 48000 GBP differs from PO 41200 by 16.5%, exceeding the 5% allowed variance.",
"evidence": {
"inputs": { "invoice_total": 48000, "po_total": 41200, "currency": "GBP", "po_number": "PO-500" },
"threshold": "5%", "computed": "16.5%", "comparison": "|16.5%| > 5%",
"sources": [{ "locator": "attachment:invoice.pdf", "snippet": "TOTAL DUE GBP 48,000.00" }]
}
}
rules_evaluated contains every rule including those that passed or were
skipped. rules_fired is the subset that objected.
| Rule | Fires when |
|---|---|
NOT_AN_INVOICE | The document is not an invoice |
INVOICE_PO_VARIANCE | Total differs from the PO beyond allowed_variance_pct |
CURRENCY_MISMATCH | Invoice currency differs from the PO |
MISSING_PO | require_po is true and no PO reference exists |
PO_NOT_VERIFIED | require_po is true, the invoice cites a PO, and no po_context was supplied to verify it |
BANK_DETAILS_CHANGED | Payment details differ from trusted_bank_details, or the invoice carries payment details and no trusted baseline was supplied |
DUPLICATE_INVOICE_NUMBER | The number matches known_invoice_numbers |
APPROVAL_THRESHOLD | Total is at or above approval_threshold |
VENDOR_IDENTITY_MISMATCH | Sender domain is not in known_domains |
LOW_EXTRACTION_CONFIDENCE | A critical field is below min_field_confidence (must be > 0.5, <= 1) |
CONFLICTING_FIELDS | The same fact appeared with two values |
UNVERIFIED_IDENTIFIER | An identifier is present but failed verification |
Critical fields for LOW_EXTRACTION_CONFIDENCE are invoice_number,
invoice_total, and vendor_name.
A PO reference on the invoice is not a PO record. MISSING_PO is satisfied by
any PO reference, including one the vendor printed. Since rules 1.3.0,
PO_NOT_VERIFIED stops that reference alone from reaching PROCESS when
require_po is true: supply the buyer-side PO as po_context, which also lets
INVOICE_PO_VARIANCE and CURRENCY_MISMATCH run. An invoice that cites a PO and
arrives without po_context now returns REQUEST_MORE_INFORMATION where rules
1.2.0 could return PROCESS.
A skipped rule is not a passed rule. If you supply no
vendor_context.known_invoice_numbers, DUPLICATE_INVOICE_NUMBER cannot run —
it appears in rules_evaluated with status: "skipped" and a skipped_reason,
and a RULE_NOT_EVALUATED warning. The more context you supply, the more of the
safety net is actually active.
BANK_DETAILS_CHANGED — four cases, three outcomes
Withholding vendor_context does not silence this rule. Four distinct cases
resolve to the three outcomes below, and telling them apart is the whole point
of the rule:
| Invoice bank details | Trusted baseline | Outcome | Decision | evidence.computed |
|---|---|---|---|---|
| present | supplied, identifiers differ | fired | HOLD_FOR_REVIEW | count of differing identifiers |
| present | supplied, identifiers match | passed | — | 0 |
| present | not supplied | fired | REVIEW | "no_baseline" |
| absent | any | skipped | — | — (skipped_reason: "no payment details found on the invoice") |
computed: "no_baseline"
A machine-readable evidence state meaning: a bank comparison could not be performed, because no trusted vendor bank baseline was supplied.
It is not evidence that bank details changed, and not fraud evidence. Do not render it to a reviewer as a change. When it is set:
evidence.comparisonreadstrusted_bank_details is absentevidence.inputs.has_trusted_recordisfalsefieldsis[]— no identifier was compared, so none is named- no
expected/observedpair is produced. Inresolution.blocking_conditionstheobservedslot carries the diagnosisno_baseline, never a bank identifier
The decision is REVIEW rather than HOLD_FOR_REVIEW because a missing
baseline is missing information, not evidence of a change. The invoice still
stops — needs_review is true either way — and the remedy is the same:
REQUEST_VENDOR_BANK_VERIFICATION, owned by VENDOR_MANAGEMENT. Populate
vendor_context.trusted_bank_details and the next invoice gets a real
comparison. Worked end to end in v1-examples.md
section D.
10. Warnings
Two arrays carry the same concerns in different forms.
warnings is an array of human-readable diagnostic strings — the engine’s
own prose about skipped rules and evidence problems, written for a person
reading a log or a support ticket. The wording is not a contract: it changes
freely and must not be parsed or pattern-matched.
warnings_structured is the machine-readable form: a typed object with a
closed code, a category, and the field or rule_id it concerns. Build
integration logic on this one.
The two are not a strict mapping. Some entries appear in both, some diagnostics
exist only as prose, and a lowered confidence on a field is itself a signal
worth reading alongside them. When the two seem to disagree, warnings_structured
and the field envelopes are authoritative.
warnings_structured is typed; prefer it over the warnings string array.
{ "code": "UNVERIFIED_FIELD", "category": "decision_affecting",
"field": "vat_number", "rule_id": null,
"detail": "identifier_contains_placeholder",
"message": "vat_number could not be confirmed against the document (identifier_contains_placeholder)." }
| Category | Meaning |
|---|---|
decision_affecting | Contributed to the outcome. A reviewer must look at it. |
data_quality | Worth recording; did not change the decision. |
| Code | Meaning |
|---|---|
UNVERIFIED_FIELD | A value could not be tied back to the document. |
RULE_NOT_EVALUATED | A safety rule could not run; required context was absent. |
MISSING_FIELD | A field Maqaas looks for was not found. |
CONFLICTING_VALUES | The same fact appeared with two values across sources. |
CALLER_ASSERTED_VERIFICATION | You supplied extracted_facts with source_verified: true. Maqaas performed no extraction and did not verify that value. |
10a. Resolution Intelligence
Every response carries a resolution block. On a clean PROCESS it is present
but empty, so you never branch on the field existing.
"resolution": {
"catalog_version": "1.4.0",
"blocking_conditions": [
{ "rule_id": "BANK_DETAILS_CHANGED", "type": "bank_details_changed",
"severity": "HOLD_FOR_REVIEW",
"fields": ["bank_details.account_number_last4", "bank_details.sort_code"],
"message": "Payment details differ from the trusted vendor record…",
"evidence": { "expected": "account_number_last4=9021, sort_code=20-00-00",
"observed": "account_number_last4=4471, sort_code=30-96-14",
"comparison": "2 of 2 compared identifier(s) differ from the trusted record (normalised); bank_name_differs=false" } }
],
"resolution_requirements": [
{ "rule_id": "BANK_DETAILS_CHANGED",
"requirement_type": "independent_bank_verification",
"acceptable_evidence": ["approved_vendor_master_update",
"verification_via_previously_trusted_supplier_contact",
"signed_bank_change_authorisation_on_vendor_letterhead"],
"instruction": "Confirm the new payment details through a channel independent of this invoice…" }
],
"recommended_action": "REQUEST_VENDOR_BANK_VERIFICATION",
"resolution_plan": [
{ "step": 1, "rule_id": "BANK_DETAILS_CHANGED",
"action": "REQUEST_VENDOR_BANK_VERIFICATION", "owner": "VENDOR_MANAGEMENT",
"requirement_type": "independent_bank_verification",
"fields": ["bank_details.account_number_last4", "bank_details.sort_code"],
"instruction": "Confirm the new payment details through a channel independent of this invoice…" },
{ "step": 2, "rule_id": null,
"action": "RESUBMIT_FOR_DECISION", "owner": "HOST_SYSTEM",
"requirement_type": "decision_re_evaluation", "fields": [],
"instruction": "Call the decision endpoint again with the updated context…" }
],
"decision_if_resolved": {
"decision": "PROCESS",
"assumption": { "resolved_rule_ids": ["BANK_DETAILS_CHANGED"],
"statement": "Assumes this condition is cleared. No facts were substituted, corrected, or invented." },
"rules_still_not_evaluated": [],
"remaining_rules_fired": [],
"caveat": "Deterministic result with the named blockers set aside…"
}
}
blocking_conditions
One entry per fired rule, reshaped for direct consumption. fields and
evidence are taken from the rule’s own structured output — nothing is derived
or inferred.
resolution_requirements
Default guidance from a static, versioned catalogue. It is authored, not
model-generated, and it is not universal policy: bank-change verification
controls genuinely differ between jurisdictions and companies. Treat it as a
sensible default to compare against your own controls. catalog_version moves
independently of rules — guidance can be reworded without any decision
changing.
recommended_action
A closed enum, because your workflow has to switch on it. Prose lives in the
requirement’s instruction.
NONE · REQUEST_PO_REFERENCE · REQUEST_CORRECTED_INVOICE ·
RESOLVE_PO_VARIANCE · REQUEST_VENDOR_BANK_VERIFICATION ·
VERIFY_VENDOR_IDENTITY · VERIFY_DUPLICATE_STATUS ·
VERIFY_EXTRACTED_FIELDS · ROUTE_TO_APPROVER · RECLASSIFY_DOCUMENT ·
HOLD_FOR_MANUAL_REVIEW
When several rules fire you get all blockers and all requirements, but one primary action, chosen by a fixed precedence ordered by remediation urgency:
REQUEST_VENDOR_BANK_VERIFICATION → VERIFY_VENDOR_IDENTITY → VERIFY_DUPLICATE_STATUS
→ RECLASSIFY_DOCUMENT → VERIFY_EXTRACTED_FIELDS → RESOLVE_PO_VARIANCE
→ REQUEST_CORRECTED_INVOICE → REQUEST_PO_REFERENCE → ROUTE_TO_APPROVER
→ HOLD_FOR_MANUAL_REVIEW → NONE
Note this is not decision severity order. APPROVAL_THRESHOLD carries the
highest decision severity (ESCALATE) while being the most benign remediation —
nothing is wrong with the invoice, it needs a signature. Payment redirection is
what a queue should see first.
resolution_plan
The ordered steps that clear every blocker, plus a terminal step telling your
software to call again. Empty on a clean PROCESS.
Everything in it is copied from the static catalogue or from the rule’s own evidence. No step ever proposes a corrected value — a plan says what must be established, never what the answer is. For an unreadable invoice number it says “confirm it against the document”, not “it is probably INV-1001”.
| Field | Meaning |
|---|---|
step | 1-based position. Contiguous, no gaps. |
rule_id | The blocker this step clears. null on the terminal step. |
action | Same enum as recommended_action, plus RESUBMIT_FOR_DECISION. |
owner | Default workflow role. See below. |
requirement_type | Stable token for the kind of remediation. |
fields | Fields the blocking rule evaluated. |
instruction | Catalogue-authored prose for a review queue. |
Ordering uses the same remediation-urgency precedence that chooses
recommended_action, so step 1 and recommended_action always agree. Rules
sharing an action are ordered by rule_id, so the plan is byte-identical across
identical requests.
One step per blocker. Two rules that map to the same action still produce two steps, because they name different fields — collapsing them would lose a blocker you are entitled to see. Clearing one blocker never silently removes another.
owner is a default, not a mandate. These are starting-point workflow roles,
expected to be mapped onto your own queues and job titles:
AP_REVIEWER · PROCUREMENT · VENDOR_MANAGEMENT · APPROVER ·
MANUAL_REVIEW · HOST_SYSTEM
HOST_SYSTEM is your software rather than a person, and appears only on the
terminal RESUBMIT_FOR_DECISION step. MANUAL_REVIEW is the fallback for a
blocker with no catalogue guidance — it still gets a step, because dropping it
would hide a blocker.
What the plan does not contain: estimated time to resolve, estimated savings, fraud probability, or revenue impact. We have no historical data that would make those honest.
decision_if_resolved — read this before relying on it
This is not a prediction. It is a deterministic counterfactual.
It answers exactly one question:
What does the engine return if the named blockers are set aside and every other supplied fact stays exactly as it is?
It is computed by removing those rules from the registry and re-running the real engine on the identical context. No fact is ever fabricated — not a matching PO, not a corrected total, not verified bank details.
That distinction matters because clearing a blocker often supplies data that
other rules then evaluate for the first time. Attach a real PO to clear
MISSING_PO and INVOICE_PO_VARIANCE and CURRENCY_MISMATCH wake up — they
were skipped for lack of PO context, not passed. So a MISSING_PO response
does not claim PROCESS outright; it reports the counterfactual alongside:
"rules_still_not_evaluated": [
{ "rule_id": "INVOICE_PO_VARIANCE", "reason": "purchase_order_not_supplied" },
{ "rule_id": "CURRENCY_MISMATCH", "reason": "purchase_order_not_supplied" }
]
Read rules_still_not_evaluated before treating decision as final. An empty
list means clearing the blockers really does settle it on the facts you supplied.
remaining_rules_fired lists findings that survive even with the blockers
cleared — if it is non-empty, resolving the named blockers is not sufficient.
11. Errors
{ "error": { "code": "OCR_REQUIRED_NOT_AVAILABLE",
"message": "This document appears to be a scan and requires OCR, which is not enabled.",
"request_id": "req_9f2c41a8b0e34d7c9a15",
"retryable": false } }
| Code | HTTP | Retryable | |
|---|---|---|---|
INVALID_INPUT | 400 · 404 | no | (404 only for an unknown route) |
UNAUTHORIZED | 401 | no | |
TENANT_SUSPENDED | 402 | no | (credential valid, account not entitled) |
USAGE_LIMIT_EXCEEDED | 402 | no | (plan allowance exhausted) |
RATE_LIMITED | 429 | yes | |
IDEMPOTENCY_IN_PROGRESS | 409 | yes | (after the original finishes) |
IDEMPOTENCY_ALREADY_COMPLETED | 409 | no | |
IDEMPOTENCY_KEY_REUSED | 409 | no | |
IDEMPOTENCY_NOT_CONFIRMED | 503 | yes | (decision withheld — see §12) |
UNSUPPORTED_MEDIA_TYPE | 415 | no | (reserved — see note) |
FILE_TOO_LARGE | 413 | no | |
TOO_MANY_PAGES | 413 | no | |
MALFORMED_DOCUMENT | 422 | no | |
EMPTY_DOCUMENT | 422 | no | |
OCR_REQUIRED_NOT_AVAILABLE | 422 | no | |
EXTRACTION_UNRELIABLE | 422 | no | |
EXTRACTION_FAILED | 502 | yes | |
INTERNAL_ERROR | 500 | yes |
Note on UNSUPPORTED_MEDIA_TYPE. The document endpoint validates
media_type against its allowed set before ingestion runs, so an unsupported
type returns 400 INVALID_INPUT with a details entry naming the field —
not a 415. The code is reserved in the enum because the ingestion layer can
raise it, but no request to /v1/invoice/decision/document currently produces
it. Branch on 400 INVALID_INPUT for this case.
Errors never contain stack traces. A non-PROCESS decision is a 200, not an
error — review is a normal outcome.
12. Traceability and idempotency
Every response carries request_id, also returned as the X-Request-Id header.
Quote it in support requests: it identifies a request without you sending us
the document again.
Idempotency-Key
Optional. Send it and a retry cannot become a second decision:
Idempotency-Key: ap-2026-04417-attempt-1
The key is scoped to your tenant — the same string used by another customer has no relationship to yours. It is opaque to us: 1–255 printable ASCII characters. We store only a hash of it, never the key itself.
| Situation | Result |
|---|---|
| First use | Processed normally. 200. |
| Retry after it completed, same request | 409 IDEMPOTENCY_ALREADY_COMPLETED. Not reprocessed, not billed again. |
| Retry while the original is still running | 409 IDEMPOTENCY_IN_PROGRESS, retryable: true. Try again once the first call returns. |
| Same key, different request content | 409 IDEMPOTENCY_KEY_REUSED. Nothing is processed. Use a new key. |
| Original failed (any error) | The key is released. Retrying with it runs the request again. |
| We could not confirm the idempotency record | 503 IDEMPOTENCY_NOT_CONFIRMED, retryable: true. The decision is withheld and not billed. Retry with the same key. |
The first two carry the original operation’s id in
error.details[0].message, under the path original_request_id. Pair it with
the request_id of the retry itself, which is always new.
We do not replay the original response. Maqaas stores no invoice, document, fact, decision or response (§ PILOT_SECURITY), and returning a stored copy would require keeping one. A duplicate is told what happened, not handed an archived answer. Keep your own copy of the first response — it is the record of the decision.
We fail closed. If the decision is computed but its idempotency record
cannot be confirmed, you get 503 instead of the decision, and nothing is
billed. Returning the decision would hand you a success whose retry-safety
silently did not hold. Retrying with the same key is the correct response.
The guaranteed window is 24 hours from completion of the original request. After that the key is forgotten and may be used again.
What is fingerprinted: everything that can change the decision — the document
bytes or text, the facts you supply, message, po_context, vendor_context
(including trusted bank details), your rules, and which endpoint you called.
JSON key order does not matter. client_reference is not part of it, so
relabelling a retry does not make it a new request.
Without the header, nothing changes. Requests remain stateless: submitting
the same invoice twice produces two independent decisions and two billable
requests. Duplicate invoice numbers are detected only when you supply
vendor_context.known_invoice_numbers.
13. Versioning and compatibility
versions carries six values. Treat them differently:
| Field | Stability |
|---|---|
api | Breaking changes require a new path (/v2/...) |
schema | Shape of extracted_facts. Additive changes possible within v1. |
rules | Rule set. A new rule bumps the minor version and can change outcomes. |
engine | Build. Informational. |
extraction | Instruction-set version. Informational; not a stability guarantee. |
resolution_catalog | Requirements, actions, owners and precedence. Moves independently of rules. |
What is safe within v1
Additive, no notice required — adding an optional response field, adding a
value to an open list (warnings_structured, rules_evaluated), adding a new
reason string on a pending rule. Parse defensively: ignore unknown fields, and
do not assume rules_fired has a fixed length.
Requires /v2 — removing or renaming a documented field, changing a field’s
type, narrowing an enum you already receive (decision, recommended_action,
verification_reason), renaming a rule_id, or changing the meaning of an
existing value.
Rule identifiers are part of the contract. rule_id values such as
BANK_DETAILS_CHANGED appear in rules_fired, rules_evaluated,
resolution.blocking_conditions, the resolution plan and RULE_NOT_EVALUATED
warnings. You are expected to switch on them, so adding an id is additive and
renaming or removing one is breaking. The published set is pinned by a contract
test; individual rules still evolve independently via their own rule_version.
The one that can change your outcomes
Adding a rule is additive to the schema but not neutral to your results:
a new rule can fire on an invoice that previously returned PROCESS. A minor
rules bump is the signal. This is a real operational risk and we will not
pretend otherwise:
- Rule sets are not pinnable today. You always get the current
RULES_VERSION; there is no request parameter to hold an older one. - Every response states
versions.rules. Record it with the decision so a change in behaviour can always be attributed. - A new rule that can raise severity will be announced before release.
If deterministic replay matters to you, store the full response — it contains every rule id, version, and piece of evidence that produced the outcome.
RULES_VERSION changes when a rule is added, removed, or its decision semantics
change. RESOLUTION_CATALOG_VERSION changes when a requirement, action mapping,
owner, or precedence changes. They move independently: guidance can be reworded
without altering a single decision, and a rule can change without new guidance.
Individual rules also carry their own rule_version in every result.
schema_version, engine_version, and rules_version remain at the top level
as compatibility aliases of the versions block.
14. Limits
| Limit | Value |
|---|---|
| Document size | 10 MB decoded |
| Pages | 30 |
| Media types | application/pdf, text/plain |
| Request body | 15 MB |
| Inline document text | 500,000 chars |
| Message body | 200,000 chars |
15. Security and privacy
- No autonomous payment execution. Maqaas recommends; it never moves money.
- Partial bank details only. Full account numbers and IBANs are never stored or returned.
- Tenant isolation. Every request is authenticated and metered per tenant.
snippetfields contain verbatim document text — real business information, sometimes personal data. Treat responses at the same classification as the source invoice.- Operational logs record request ID, tenant, decision, rule IDs, and timings. Document text is not written to application logs.
- We make no compliance-certification claims (SOC 2, ISO 27001, PCI). Ask for current status before relying on one.
16. Example integration flow
- Invoice arrives in your AP inbox.
- Look up the PO and vendor record in your own system.
POST /v1/invoice/decision/documentwith the PDF pluspo_context,vendor_context, and yourrules. Supply as much context as you have — an omitted context block silently disables the rules that depend on it.- Branch on
decision:PROCESS→ continue your normal flow- anything else → queue to
recommended_route
- Show the reviewer
reasons,warnings_structured, and theevidenceof each fired rule. That is enough to explain the outcome without re-reading the PDF. - Store
request_idagainst your invoice record.
17. What is not in v1
No webhooks, callbacks, or async jobs — every call is synchronous. No stored history, search, or retrieval. No OCR for scanned documents. No email sending or vendor follow-up. No multi-invoice PDFs — a file containing two invoices is reported as conflicting rather than split. No line-item extraction.