REST API
The REST API exposes the same catalogue as the MCP endpoint, plus the routes that have no MCP equivalent: webhooks, runs and usage. Every route takes a bearer token and answers JSON.
- Base URL:
https://mandatum.intergentech.ai/api/v1/ - Authentication:
Authorization: Bearer <token>, with an API key or an OAuth access token. See Authentication. - OpenAPI 3.1 document:
GET /api/v1/openapi.json. It is public and needs no key. It is the source of truth for every request and response schema on this page. - Interactive reference: API reference, with a try-it console that uses your key.
Each route needs one scope. The scope column below is the one the route checks. A key without it gets 403 with "code": "tool_not_enabled_for_key".
Errors use one shape across the API, and every error body carries a request_id that is also returned in the x-request-id header. See Errors. Calls are metered in the units described in Usage and units. Limits are described in Rate limits. To get a key and make a first call, follow the Quickstart. Who may call which route, and how scopes are granted, is in Authentication.
SDKs #
Two SDKs are generated from this contract and published with the same version number: @mandatum/sdk for TypeScript and mandatum for Python. Both retry a 429 honouring Retry-After, raise a typed error that carries code and request_id from the error body, and expose one method per operation.
TypeScript (npm install @mandatum/sdk):
import { MandatumClient } from "@mandatum/sdk";
const client = new MandatumClient({ apiKey: process.env.MANDATUM_API_KEY ?? "" });
const { results } = await client.statutes.search({ query: "companies", jurisdiction: ["BB"] });
const verdicts = await client.citations.verify({ citations: ["Companies Act, Cap. 308"], jurisdiction: "BB" });Python (pip install mandatum):
import os
from mandatum import Mandatum
client = Mandatum(api_key=os.environ["MANDATUM_API_KEY"])
result = client.statutes.search("companies", jurisdiction=["BB"])
verdicts = client.citations.verify({"citations": ["Companies Act, Cap. 308"], "jurisdiction": "BB"})statutes.search accepts jurisdiction as one code, a list, or all. citations.verify takes one code. The base URL defaults to https://mandatum.intergentech.ai and must be https; plain http is allowed only for localhost. Each call makes at most four attempts on a 429, waits at most 60 seconds for each Retry-After, and each attempt has its own timeout. Each package's README has the full option tables.
Statutes #
GET and POST /api/v1/statutes/search #
Full-text search over the shared statute corpus. Scope: statutes:read, or searchStatutes on keys that carry the legacy statute scope.
Query parameters for GET:
| Parameter | Required | Meaning |
|---|---|---|
query |
Yes | The search text. |
jurisdiction |
No | One or more jurisdiction codes. Repeat the parameter (jurisdiction=BB&jurisdiction=TT) or give a comma-separated list (jurisdiction=BB,TT). all searches every jurisdiction. |
limit |
No | Maximum results. Default 10. |
An empty jurisdiction (jurisdiction=) means "not provided". Use all to search everything.
POST takes the same fields as JSON, with jurisdiction as an array:
{ "query": "data protection", "limit": 5, "jurisdiction": ["BB", "TT"] }The q name is still accepted as an alias of query. Use query in new code.
Jurisdiction resolution. When no jurisdiction is sent, the workspace's home jurisdiction applies, if it has one. applied_filter.source says which rule ran.
| What the caller sends | Filter applied | applied_filter.source |
|---|---|---|
| One or more codes | Those codes | param |
all |
None | param |
| Nothing, and the workspace has a home jurisdiction | The home jurisdiction | home |
| Nothing, and no home jurisdiction | None | none |
An owner or admin sets the home jurisdiction under Settings → General in the workspace. An unknown code returns 400. It is never ignored.
Response (200):
{
"results": [
{
"id": "<uuid>",
"section_number": "<string>",
"title": "<string or null>",
"body": "<string>",
"statute_id": "<uuid>",
"jurisdiction": "BB"
}
],
"applied_filter": { "jurisdictions": ["BB"], "source": "home" }
}applied_filter.jurisdictions is null when no restriction applied. Every result carries its jurisdiction.
GET /api/v1/statutes/{cap} #
Returns sections of one statute by its CAP number, for example 309A. Scope: statutes:read. Use the optional sectionId query parameter to fetch one section.
Citations #
POST /api/v1/citations/verify #
Resolves citation strings against the corpus. Each citation resolves to a statute and, where it names one, a section. Scope: citation:verify.
{
"citations": ["Companies Act, Cap. 308, s. 12", "Data Protection Act, 2019-29 s. 9"],
"jurisdiction": "BB",
"as_of": "2026-10-22"
}| Field | Meaning |
|---|---|
citations |
One or more citation strings. At most 50 per request, each at most 512 characters. |
citation |
A single citation. Equivalent to a one-item citations array. |
jurisdiction |
A hint such as BB. Chapter numbers repeat across Commonwealth jurisdictions, so pass one when you know it. |
as_of |
YYYY-MM-DD. Defaults to today. |
A malformed request returns 400 with "code": "bad_request" and the list of issues. It never returns 500.
Response (200), one object per citation, in input order:
{
"results": [
{
"citation": "Companies Act, Cap. 308, s. 12",
"found": true,
"match": "exact",
"statute_id": "<uuid>",
"section_id": "<uuid>",
"title": "Companies Act",
"section_heading": "Incorporation",
"jurisdiction": "BB",
"source_url": "<url or null>",
"confidence": 0.99,
"reason": "cap_and_section",
"pinpoint": null,
"as_of": "2026-08-27",
"verified_at": "2026-08-27T18:04:11.000Z",
"current": null,
"repealed": null,
"amended_by": null,
"currency": {
"status": "unknown",
"effective_from": null, "effective_to": null, "as_of_source_date": null,
"source": null, "source_url": null, "checked_at": null,
"unapplied_effects": null, "confidence": null
},
"currency_state": "unknown",
"unknown_reasons": ["amendment_source_never_consulted", "no_currency_source"],
"verdict": "resolved"
}
],
"verified_at": "2026-08-27T18:04:11.000Z",
"units": [{ "unit": "verified_citation", "count": 1 }]
}verdict: what the resolver decided #
Branch on verdict to decide whether a citation is usable. match says how it resolved; verdict says what resolved.
verdict |
Derived when | Billed as verified_citation |
|---|---|---|
resolved |
A statute and a section both resolved. Currency does not affect this. | 1 |
not_found |
Nothing resolved. | 0 |
ambiguous |
More than one candidate statute matched. | 0 |
section_designation_unknown |
A statute resolved, but no single section did. This covers a multi-section citation (ss. 12-26), a section the statute does not carry, and a whole-statute citation. |
0 |
The rules are checked in that order. The resolver decides verdict; what you do with it is up to you.
match: what each value obliges you to do #
match |
found |
Meaning | What to do |
|---|---|---|---|
exact |
true |
A deterministic identifier resolved the citation: a chapter number, an Act number, or a short title that matches the corpus title. A cited section exists. | Safe to act on statute_id and section_id. |
title-fuzzy |
true |
No identifier matched. The statute was chosen by token overlap of at least 0.8 with its short title, after instrument words are removed. | A suggestion. Show title to a person, or re-cite with a chapter or Act number. Do not present it as verified. |
not_found |
false |
The citation does not resolve. reason says why. |
Treat it as unverified. reason is stable enough to branch on. |
ambiguous |
false |
Several statutes or sections match. reason carries the count. |
Narrow it with jurisdiction, or split a multi-section citation into one call per section. |
A fuzzy match is never reported as exact. Without a jurisdiction hint, a chapter number that exists in more than one jurisdiction is ambiguous, not a guess.
confidence measures resolution only: 0.99 for chapter and section, 0.95 for chapter alone or an exact short title, 0.90 for an Act number, 0.85 for a chapter recovered from the title text, the overlap score (capped at 0.90) for title-fuzzy, and 0 for anything unresolved. Currency has its own confidence, in currency.confidence.
Currency is tri-state, and currently often unknown #
current, repealed and amended_by are each true, false or null. null means unknown, and it is never treated as false. For amended_by, an empty array means an amendment source was consulted and found none; null means it was never consulted.
currency_state collapses the answer to current, repealed or unknown. A prospective provision reads unknown there, while currency.status still says prospective.
found: true does not mean "in force". It means the provision exists in the corpus as we hold it. Anything that turns on whether the law is current must wait for current to be true. The full list of unknown_reasons is in the tool catalogue.
How titles are matched #
Barbados stores short titles without the instrument word (Accidental Fires) and writes Act numbers as a suffix (Data Protection Act, 2019-29). Citations use the form they are written in. The resolver compares them in this order:
- Chapter number.
Cap. Nmatches the stored chapter number, or aCap. Nin the stored title if none is stored. When a citation names an Act and its chapter is not stored, the result isnot_foundwith reasonact_title_cap_unverified. A stored chapter that differs iscap_title_mismatch. Chapters compare after normalisation, so015and15are the same. - Citable title. The Act name is reduced to a key and matched exactly. A year in the citation must match the stored year. A citation year the stored title does not carry resolves as
title-fuzzywith reasonact_title_year_unverified. Two matches areambiguous_title. - Stored title. The token and fuzzy path. Only this step yields
title-fuzzywithout a year gap.
A citation resolved at step 2 with a year the title carries is exact. A citation that needs step 3 is title-fuzzy and must not be presented as verified.
A pinpoint is attested or it is refused #
section_designation and section_designation_source carry the designation the corpus holds for the resolved section, and what attests it. Render section_designation, never the number the request asked with. Both are null whenever no section resolved, including when the section exists and only its number is unknown to us.
Three reason values report a pinpoint the corpus cannot answer. None of them says that the section does not exist:
reason |
Meaning | Caller's obligation |
|---|---|---|
section_designation_unknown |
The Act resolved, but we hold no designations for its sections, so the cited one cannot be checked. Carries no count. | Anchor to the statute and say the pinpoint is unverified. |
section_partially_designated |
The Act resolved and some rows carry designations, but the cited section is not among them and some rows have none. Its absence is not asserted. | Treat as unverified. Retry after the Act's backfill completes. |
ambiguous_section_in_corpus:N |
N rows claim the cited designation within the same container. A corpus defect. |
Treat as unverified and report the collision. |
section_not_found is reserved for an Act whose every row carries a designation, where the cited section is not among them.
units: what the call bills #
units has one entry, { "unit": "verified_citation", "count": n }, where n is the number of results whose verdict is resolved. The entry is present even when n is 0. Unresolved citations never bill.
Gap analysis #
POST /api/v1/gaps #
Returns the gap items your workspace has recorded against one statute. The call reads gap items already recorded; it does not analyse the statute. Scope: gap:read.
{ "statuteId": "<uuid>" }Response (200):
{
"gaps": [
{ "id": "<uuid>", "description": "<string>", "gap_analysis_id": "<uuid>" }
]
}POST /api/v1/gaps/{analysisId}/issue #
Issues the gap report for one analysis. The report is the gap items and resolved citations as they stand now, frozen under a SHA-256 content hash (content_sha256). Scope: gap:issue.
Path parameter: analysisId, the UUID of the analysis. An analysis outside your workspace returns 404 with "code": "not_found".
Response (200):
{
"issuance_id": "<uuid>",
"price_usd": 0,
"is_free": true,
"reused": false,
"content_sha256": "<64 hex characters>"
}- Issuing identical content again returns the existing issuance with
"reused": true. Nothing is billed, and nogap_reportusage row is written. - The first issuance in a workspace is free (
is_free: true,price_usd: 0). Later issuances are priced atprice_usd, which is fixed on the issuance when it is made. - The free report is per workspace, and a user can own up to three workspaces, so one user can obtain at most three free reports.
- Issuing needs an active owner, admin or member of the workspace. For a key, that means the key's owner. Guests and agent members cannot issue.
- An analysis with no resolved citation cannot be issued (
422,"code": "no_resolved_citations"). - Downloading an issued report is never billed. Downloads are available in the app only.
Errors: 400 (bad_request) when analysisId is not a UUID; 403 (tool_not_enabled_for_key without gap:issue, or forbidden when the key's owner may not issue here); 404 (not_found); 422 (no_resolved_citations); 503 (gap_issuance_unavailable) while the deployment does not yet have the gap-report tables.
Issuance is metered: see Usage and units.
Benchmarking #
POST /api/v1/benchmark/compare #
Title match of statutes across the given jurisdictions. It returns the matching statute titles. It does not compare provisions. Scope: benchmark:read.
{ "jurisdictions": ["BB", "TT"], "topic": "companies" }Workflows and runs #
GET /api/v1/skills #
Lists the workflow definitions available to you. Scope: skills:read.
{ "skills": [{ "slug": "<string>", "displayName": "<string>" }] }POST /api/v1/skills/run #
Starts a workflow run. It returns straight away with a run id, and the work happens in the background. Scope: skills:trigger.
Two refusals happen before a run is enqueued:
402with"code": "BUDGET_EXCEEDED": the workspace has reached its monthly agent budget. The body names the ceiling, the month-to-date spend and the estimate.429with"code": "too_many_requests": too many runs were started in the last minute. TheRetry-Afterheader gives the seconds to wait.
A streamed model call that the client aborts or that errors is settled on the usage observed so far (metadata.settled: partial). The pre-run estimate is recorded (settled: estimate) only when no usage was observed at all.
{ "matterId": "<uuid>", "workflowSlug": "<string>", "inputs": {} }The response carries runId and workflowSlug. To learn the outcome, either subscribe to a webhook or poll the run.
GET /api/v1/runs/{id} #
Returns the state of one run. Scope: runs:read.
{
"id": "<uuid>",
"status": "queued | running | awaiting_hitl | completed | failed | cancelled",
"output_id": "<uuid or null>",
"error": "<string or null>",
"started_at": "<RFC 3339 or null>",
"completed_at": "<RFC 3339 or null>",
"usage": null
}usage sums the run's metered token rows, or is null when none were recorded, which means "not measured". A run that belongs to another workspace returns 404, exactly as a run that does not exist does.
Matters #
Read-only access to Matters, the workspace's matter files. Scope: matters:read.
GET /api/v1/matters: list matters.GET /api/v1/matters/{id}: one matter.GET /api/v1/matters/{id}/documents: documents attached to a matter.GET /api/v1/matters/{id}/conversations: conversations linked to a matter, as summaries.
A key sees only the matters its owner belongs to. A matter the owner is not a member of is reported as absent: GET /api/v1/matters/{id} returns 200 with { "matter": null }. Read Data egress before enabling this scope.
Evidence export #
GET /api/v1/evidence/export #
Returns a zip evidence bundle for your workspace and a period: a manifest, usage by day, gap report issuances, verified citations and audit rows. Scope: usage:read, and the key's owner must be a workspace owner or admin, the same gate as GET /api/v1/usage.
Query parameters: from and to, both required. from is inclusive and to exclusive, each an ISO-8601 calendar date or a date-time with a zone. to must be later than from, and the period may span at most 397 days.
Response (200): application/zip, with a Content-Disposition attachment named for the workspace and the period.
| Status | code |
When |
|---|---|---|
400 |
bad_request |
from or to is missing, not ISO-8601, or from is not before to. |
400 |
invalid_range |
The period spans more than 397 days. |
403 |
tool_not_enabled_for_key |
The key lacks usage:read. |
403 |
forbidden |
The key's owner is not a workspace owner or admin. |
413 |
EVIDENCE_TOO_LARGE |
The uncompressed payload or the zip exceeds 4 MB. Narrow the period. |
500 |
internal_error |
A read failed. The request_id matches the server log. |
Each data file holds at most 50,000 rows, the oldest first. A truncated file is marked in the manifest. The export is audited. The bundle format is a draft, schema version 1, and its column names may still change before they are agreed.
Documents #
POST /api/v1/documents #
Parses an uploaded document. Scope: documents:write.
- Send
multipart/form-datawith afilepart (PDF, DOCX, UTF-8 text or Markdown, at most 50 MB) and an optionalenginepart:tree(the default, a typed document tree) ordocling(raw DoclingDocument JSON). - The request must carry a finite
Content-Length. Without one, the response is411. - Nothing is stored. The only record is the usage row and the audit entry.
Usage #
GET /api/v1/usage #
Metered totals for your workspace over a time window. Scope: usage:read, and the key's owner must be a workspace owner or admin. Full details, including the units and parameters, are in Usage and units.
POST /api/v1/gaps/{analysisId}/issue writes a call row and, for a new issuance, a gap_report row.
Webhooks #
Six routes manage webhook endpoints. The signature scheme, retry schedule, replay and rotation are described in Webhooks and runs.
| Method | Path | Scope |
|---|---|---|
POST |
/api/v1/webhooks |
webhooks:write |
GET |
/api/v1/webhooks |
webhooks:read |
DELETE |
/api/v1/webhooks/{id} |
webhooks:write |
POST |
/api/v1/webhooks/{id}/rotate |
webhooks:write |
GET |
/api/v1/webhooks/{id}/deliveries |
webhooks:read |
POST |
/api/v1/webhooks/{id}/deliveries/{deliveryId}/replay |
webhooks:write |
POST /api/v1/webhooks takes a url, a list of events, and an optional description of up to 500 characters. The response carries the signing secret, which starts with whsec_, once.
Removed routes #
GET and POST /api/v1/gazette/search and POST /api/v1/drafts/generate were removed. An authenticated call returns 410 Gone with "code": "gone" and a see field that points to the changelog. Unauthenticated calls still get 401.
Errors and limits #
Every error body is { "error": string, "code"?: string, "request_id": string }, and the same id is returned in the x-request-id header, so you can read it without parsing the body. code is present only where the route sets one. Read Errors for the status codes and the codes. Read Rate limits for the 429 responses: the general request limit does not send Retry-After, while the workflow-run limit does.
MCP #
The same tools are available to MCP clients at POST /api/mcp. See the MCP endpoint and the tool catalogue.