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

MethodPathUse when
POST/v1/invoice/decisionYou have invoice text, an email body, or already-extracted facts
POST/v1/invoice/decision/documentYou have a PDF or text file
GET/healthLiveness. No auth. Touches nothing.
GET/readyReadiness — 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_confidence defaults to 1 when 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 no MISSING_FIELD warning.
  • Relaxing presence does not relax validation. Any field you do send is validated exactly as before — a malformed invoice_total, or a confidence outside [0, 1], still fails with 400 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

DecisionSeverityRouteMeaning
PROCESS0—No evaluated rule objected. Check warnings_structured for RULE_NOT_EVALUATED before treating it as fully checked.
REVIEW40accounts_payable_clerkSomething needs a human look
REQUEST_MORE_INFORMATION50accounts_payable_clerkMissing input, e.g. no PO reference
HOLD_FOR_REVIEW70accounts_payable_managerPayment-risk signal, e.g. changed bank details
ESCALATE85finance_controllerAbove your approval threshold
BLOCK100vendor_riskReserved — 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:

  • ESCALATE is produced only by APPROVAL_THRESHOLD, which is skipped unless you send rules.approval_threshold. Without one, it cannot occur.
  • BLOCK is 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_facts yourself, Maqaas runs no extraction, so it verifies nothing. Any source_verified and model_confidence you send are echoed back as you sent them — they are your assertions, not a Maqaas check. extraction.mode is caller_supplied in that case, and every field claiming source_verified: true also produces a CALLER_ASSERTED_VERIFICATION warning. 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

ValueMeaning
trueMaqaas checked: the cited snippet exists in the document and contains this value. On the caller-supplied path this is your own claim, echoed.
falseThe check ran and failed
nullNo 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

Reasonsource_verifiedMeaning
exact / normalizedtrueSnippet found, value present
no_snippet / no_source / snippet_too_shortnullNothing to check against
not_foundfalseCited snippet is not in the document
value_not_supportedfalseSnippet is real but does not contain the value
source_encoding_degradedfalseAn unreadable glyph sits inside the identifier
identifier_contains_placeholderfalseThe 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.

RuleFires when
NOT_AN_INVOICEThe document is not an invoice
INVOICE_PO_VARIANCETotal differs from the PO beyond allowed_variance_pct
CURRENCY_MISMATCHInvoice currency differs from the PO
MISSING_POrequire_po is true and no PO reference exists
PO_NOT_VERIFIEDrequire_po is true, the invoice cites a PO, and no po_context was supplied to verify it
BANK_DETAILS_CHANGEDPayment details differ from trusted_bank_details, or the invoice carries payment details and no trusted baseline was supplied
DUPLICATE_INVOICE_NUMBERThe number matches known_invoice_numbers
APPROVAL_THRESHOLDTotal is at or above approval_threshold
VENDOR_IDENTITY_MISMATCHSender domain is not in known_domains
LOW_EXTRACTION_CONFIDENCEA critical field is below min_field_confidence (must be > 0.5, <= 1)
CONFLICTING_FIELDSThe same fact appeared with two values
UNVERIFIED_IDENTIFIERAn 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 detailsTrusted baselineOutcomeDecisionevidence.computed
presentsupplied, identifiers differfiredHOLD_FOR_REVIEWcount of differing identifiers
presentsupplied, identifiers matchpassed—0
presentnot suppliedfiredREVIEW"no_baseline"
absentanyskipped—— (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.comparison reads trusted_bank_details is absent
  • evidence.inputs.has_trusted_record is false
  • fields is [] — no identifier was compared, so none is named
  • no expected/observed pair is produced. In resolution.blocking_conditions the observed slot carries the diagnosis no_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)." }
CategoryMeaning
decision_affectingContributed to the outcome. A reviewer must look at it.
data_qualityWorth recording; did not change the decision.
CodeMeaning
UNVERIFIED_FIELDA value could not be tied back to the document.
RULE_NOT_EVALUATEDA safety rule could not run; required context was absent.
MISSING_FIELDA field Maqaas looks for was not found.
CONFLICTING_VALUESThe same fact appeared with two values across sources.
CALLER_ASSERTED_VERIFICATIONYou 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.

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

FieldMeaning
step1-based position. Contiguous, no gaps.
rule_idThe blocker this step clears. null on the terminal step.
actionSame enum as recommended_action, plus RESUBMIT_FOR_DECISION.
ownerDefault workflow role. See below.
requirement_typeStable token for the kind of remediation.
fieldsFields the blocking rule evaluated.
instructionCatalogue-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 } }
CodeHTTPRetryable
INVALID_INPUT400 · 404no(404 only for an unknown route)
UNAUTHORIZED401no
TENANT_SUSPENDED402no(credential valid, account not entitled)
USAGE_LIMIT_EXCEEDED402no(plan allowance exhausted)
RATE_LIMITED429yes
IDEMPOTENCY_IN_PROGRESS409yes(after the original finishes)
IDEMPOTENCY_ALREADY_COMPLETED409no
IDEMPOTENCY_KEY_REUSED409no
IDEMPOTENCY_NOT_CONFIRMED503yes(decision withheld — see §12)
UNSUPPORTED_MEDIA_TYPE415no(reserved — see note)
FILE_TOO_LARGE413no
TOO_MANY_PAGES413no
MALFORMED_DOCUMENT422no
EMPTY_DOCUMENT422no
OCR_REQUIRED_NOT_AVAILABLE422no
EXTRACTION_UNRELIABLE422no
EXTRACTION_FAILED502yes
INTERNAL_ERROR500yes

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.

SituationResult
First useProcessed normally. 200.
Retry after it completed, same request409 IDEMPOTENCY_ALREADY_COMPLETED. Not reprocessed, not billed again.
Retry while the original is still running409 IDEMPOTENCY_IN_PROGRESS, retryable: true. Try again once the first call returns.
Same key, different request content409 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 record503 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:

FieldStability
apiBreaking changes require a new path (/v2/...)
schemaShape of extracted_facts. Additive changes possible within v1.
rulesRule set. A new rule bumps the minor version and can change outcomes.
engineBuild. Informational.
extractionInstruction-set version. Informational; not a stability guarantee.
resolution_catalogRequirements, 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

LimitValue
Document size10 MB decoded
Pages30
Media typesapplication/pdf, text/plain
Request body15 MB
Inline document text500,000 chars
Message body200,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.
  • snippet fields 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

  1. Invoice arrives in your AP inbox.
  2. Look up the PO and vendor record in your own system.
  3. POST /v1/invoice/decision/document with the PDF plus po_context, vendor_context, and your rules. Supply as much context as you have — an omitted context block silently disables the rules that depend on it.
  4. Branch on decision:
    • PROCESS → continue your normal flow
    • anything else → queue to recommended_route
  5. Show the reviewer reasons, warnings_structured, and the evidence of each fired rule. That is enough to explain the outcome without re-reading the PDF.
  6. Store request_id against 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.