API reference

Plain REST over HTTPS with JSON responses. Every checker is free and rate-limited: 20 calls a week without a key, 100 a week with an account-owned API key. Reads and the sandbox need nothing. All endpoints live under https://www.stipple.sh.

Conventions

  • Document inputs are accepted three ways: multipart file, JSON {"bytes_b64": "...", "filename": "..."}, or JSON {"url": "https://..."} (public hosts only; fetched server-side with strict SSRF protections; analysis endpoints only).
  • Long-running calls support ?stream=1 — a Server-Sent Events stream (receivedworking heartbeats → result) so proxies never time out mid-analysis.
  • Identical bytes are cached by content hash — re-submitting the same document is free and instant.
  • Errors use standard status codes with a JSON detail message — see Errors & limits.
  • OpenAPI: the full machine-readable contract is published at https://www.stipple.sh/openapi.json — generate a typed client or import it into your tooling.
  • Sandbox: add ?sandbox=true (or the header Stp-Sandbox: true) to any tool for a deterministic, clearly-labelled fixture response ("sandbox": true) — free, and it never counts against your weekly limit.
  • Idempotency: send an Idempotency-Key header on a POST so a retry replays the stored response instead of processing twice (a reused key with a different body is a 422).
  • Rate-limit headers: every response carries RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset so a client can self-throttle.

Fact-check a document

POST /v1/verify-references

Verifies a document's citations (live / archived / dead, scholarly papers matched against their claimed titles), recomputes its internal arithmetic, and — in deep mode — fetches each cited source to judge whether it supports the claim. Results persist under a content-hash id and include a public shareable permalink.

ParameterTypeDescription
file | text | bytes_b64 | urlbodyThe document: multipart file, raw prose, inline base64, or a public URL.
deepquery, boolAlso fetch each cited source and judge support/contradiction (slower, ~a minute).
streamquery, boolReturn an SSE lifecycle stream instead of a single response.
bash
curl -X POST "https://www.stipple.sh/v1/verify-references?deep=1&stream=1" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/report.pdf"}'

Key response fields: summary (totals + headline), references[], claims[], internal_consistency[], limitations, check_id, permalink.

Retrieve a fact-check

GET /v1/fact-checks/{check_id}

Fetch a stored fact-check result by its content-hash id (fc_ + 16 hex). This is the data behind the public /fc/<slug>-<hex> permalink pages.

bash
curl https://www.stipple.sh/v1/fact-checks/fc_6d22fb4033cbac40

Detect AI-written text

POST /v1/detect-ai-text

Estimates the probability that a document's prose was AI-written, with the specific linguistic tells. Abstains (applicable: false) on forms, tables, and scans, where style detection is unreliable. Stateless — nothing is stored.

ParameterTypeDescription
file | text | bytes_b64 | urlbodyThe document or prose to analyse.
json
{
  "applicable": true,
  "probability": 0.92,
  "lean": "ai",
  "tells": ["uniform paragraph rhythm", "hedged superlatives", ...],
  "reasoning": "...",
  "limitations": "Probability is a confidence, not a calibrated truth. ..."
}

Check text against published sources

POST /v1/check-source-overlap

Finds whether submitted text overlaps text published on the public web — a plagiarism-style check. Exact matches are confirmed against the fetched source page and returned as quotes from both sides with char spans and the URL. A second tier — clearly-labelled model judgements on possible paraphrases, in possible_paraphrases, never mixed with exact matches — is currently disabled pending its evaluation gate: until it passes, results carry an empty list, paraphrase_checked: false, and a note saying so. Reports overlap, never accusations — no match is never proof of originality. Free, like every checker — counted against your weekly limit only when outcome: "assessed"; abstentions (applicable: false) and search outages don't count. Stateless — nothing is stored.

ParameterTypeDescription
file | text | bytes_b64 | urlbodyThe document or prose to check. A url’s own site is excluded from matches.
json
{
  "outcome": "assessed",
  "overlap_band": "notable",
  "overlap_ratio": 0.14,
  "matches": [{
    "tier": "exact",
    "source_url": "https://example.com/reference-handbook",
    "matched_quote": "...",
    "source_quote": "...",
    "passage_span": {"start_char": 118, "end_char": 214},
    "tokens_matched": 17
  }],
  "possible_paraphrases": [],
  "paraphrase_checked": false,
  "note": "Paraphrase comparison is not enabled on this deployment — the exact-match tier still ran.",
  "top_sources": [...],
  "headline": "Matching published text found at 1 source ...",
  "limitations": "Public-web overlap only ... never an originality certificate. ..."
}

Extract document fields

POST /v1/extract

Pull structured fields out of any document. A vision model reads up to a few pages and returns the values you ask for. Say what to pull four ways: an ad-hoc fields list (names, or {name, type, description}, where type is text / amount / date / boolean); a template (payslip, tax_invoice, bank_statement, receipt, contract); neither (auto — classify, then apply that type's fields); or auto on an unrecognised type (schema-free — every labelled field). Reads checkboxes (type: boolean). Extraction, not verification — values are what the document shows; the document is never stored.

options turns on extra capabilities, every one off by default — and asking for one this server does not support is an error naming it, never a silent no-op. tables returns whole tables as rows: on a born-digital PDF they are read deterministically from the document's own coordinates (exact cells, merged-cell colspans, no charge for the table pass); on a scan the vision model reads the rows and the table says source: "vlm". grounding adds a bounding box and text_layer_match to every value the PDF's own text layer can locate — a scan has no coordinate source yet, so its values return bbox: null. classify and redact fold the classification verdict and the masked PII inventory into the same call. layout, links and figures return the document's structure; chunks returns retrieval-ready chunks (six strategies, heading path and position on every chunk; tables are kept whole by default); split returns the page ranges of the distinct documents in one file; flag_below lists the fields under a confidence threshold. GET /v1/extract/capabilities (free, no key) is the machine-readable contract for exactly what this server supports.

ParameterTypeDescription
file | bytes_b64 | urlbodyThe document: multipart PDF/image, inline base64, or a public URL (SSRF-guarded).
fieldsbody / queryOptional ad-hoc field list — names, or {name, type, description|question}. As a query param, repeat ?fields=.
templatebody / queryOptional preset: payslip | tax_invoice | bank_statement | receipt | contract.
countrybody / queryOptional hint (e.g. AU, IN, US) to sharpen the read.
max_pagesbody / queryPages to read per document (default a few; hard ceiling 10). Also caps the charge.
optionsbody / queryCapabilities to turn on: tables, grounding, classify, redact, layout, links, figures, chunks, split, flag_below. JSON body takes an object (tables and chunks tune with nested settings); a query string sets each as a flat param, e.g. ?tables=true&grounding=true.
render_scalebody / queryRasterisation scale for small or dense print: 1.0 | 1.5 | 2.0 (default) | 3.0 | 4.0.
json
{
  "mode": "fields",
  "document_type": "document",
  "fields": {
    "gross_pay": { "value": 5800, "confidence": 0.95, "page": 1 },
    "consent_checkbox": { "value": "checked", "confidence": 0.98, "page": 1 }
  },
  "not_found": ["middle_name"],
  "pages_read": 1,
  "page_limit": 3,
  "page_count": 1,
  "capabilities_used": ["tables.native_pdf"],
  "tables": [ { "page": 1, "source": "native_pdf", "header": ["Description", "Amount"],
                "json": [ { "Description": "Consulting", "Amount": "1000.00" } ] } ],
  "limitations": "Extraction, not verification - values are what the document shows. ..."
}

Free, like every checker — counted against your weekly limit once per call regardless of page count or which options (grounding, layout, links, figures, chunks, redact, split, classify) are requested.

Verify identity (AU 100-point)

POST /v1/identity-check

Run an Australian identity check over a set of documents. A vision model reads each one — its type, which identity fields it shows (name / photo / address / signature), and its issue date — then a deterministic engine tallies them against a scheme: afp_100_point (points, the default) or austrac_safe_harbour (category combinations). It reports whether identity is established and exactly what's missing if not. Identity coverage, not a forgery judgment — run /v1/warrants for authenticity. Stateless; documents are never stored.

ParameterTypeDescription
filesbody, multipartOne or more documents (PDF/image). Or JSON documents[] (below).
documentsbody, JSONArray of {"url": "…"} or {"bytes_b64": "…", "filename": "…"} (up to 10).
schemequery | bodyafp_100_point (default) or austrac_safe_harbour.
json
{
  "established": true,
  "rule": "points_accumulation",
  "points": 110,
  "target": 100,
  "primaries": 2,
  "reason": "Identity established: 110 points with 2 primary document(s).",
  "documents": [
    { "classified_type": "australian_passport", "shows": ["N", "P"],
      "points": 70, "counts": true, "note": "+70 pts (primary)" },
    { "classified_type": "drivers_licence", "shows": ["N", "A", "P"],
      "points": 40, "counts": true, "note": "+40 pts (primary)" }
  ],
  "limitations": "Verification COVERAGE, not a legal identity decision. ..."
}

Check a document pack (completeness)

POST /v1/check-pack

Does a set of documents satisfy a checklist? Each document is classified (one cheap page-1 read, hash-cached — never field extraction or multi-page reads), then a deterministic engine maps the types onto the checklist's required slots and reports what's present and what's missing. Supply a named scheme (income_proof, lending_prequal, rental_application) or an ad-hoc requirements checklist. Completeness coverage, not approval — that the right document types are present, not that any is genuine.

ParameterTypeDescription
filesbody, multipartOne or more documents (PDF/image), up to 12. Or JSON documents[] (below).
documentsbody, JSONArray of {"url": "…"} or {"bytes_b64": "…", "filename": "…"}.
schemequery | bodyincome_proof | lending_prequal | rental_application.
requirementsquery | bodyAd-hoc checklist: type names, or {key, accepts:[types], optional}. As a query param, repeat ?requirements=.
json
{
  "scheme": { "id": "lending_prequal", "name": "Lending pre-qualification" },
  "complete": false,
  "slots": [
    { "key": "proof_of_income", "label": "Proof of income", "satisfied": true, "matched": "payslip.pdf" },
    { "key": "bank_statement", "label": "Recent bank statement", "satisfied": false, "matched": null }
  ],
  "missing": ["bank_statement"],
  "documents": [{ "filename": "payslip.pdf", "classified_type": "payslip" }],
  "unmatched_documents": [],
  "limitations": "Completeness COVERAGE, not approval - that the right document types are present. ..."
}

Screen adverse media & sanctions

POST /v1/adverse-media

Screen a person or organisation for adverse media and sanctions exposure (PEP is not screened, and the reply says so). Two layers: a deterministic, corroboration-gated match against four primary sanctions lists (OFAC, EU, UK, AU DFAT), and an entity-disambiguated scan of public news for adverse coverage (fraud, money laundering, bribery, sanctions, enforcement). Same-name articles about other people are surfaced under excluded, and a name-only sanctions hit is flagged possible, never confirmed. Screening coverage, not a determination — a hit means “review”, and “nothing found” is not a clean record.

ParameterTypeDescription
namebodyThe subject to screen. Provide this, OR a document to read the subject from.
dob, country, aliases, employer, rolebodyOptional disambiguators — they sharpen matching and cut same-name false positives.
file | bytes_b64 | urlbodyAlternatively, an identity document (multipart / base64 / public URL) to read the subject from.
json
{
  "subject": { "name": "Jane Doe", "source": "provided", "country": "AU" },
  "sanctions": {
    "candidates": [
      { "name": "Jane Doe", "score": 0.93, "strength": "possible",
        "corroboration": "name-only", "topics": ["role.pep"], "datasets": 3 }
    ],
    "strong_matches": 0, "possible_matches": 1, "common_name_ambiguity": false
  },
  "adverse_media": {
    "matched_hits": 1, "possible_hits": 0, "excluded_same_name": 2,
    "categories": { "financial_crime": 1 },
    "hits": [
      { "url": "https://…", "title": "…", "source": "law360.com",
        "match": "match", "category": "financial_crime", "severity": "convicted",
        "evidence": "found guilty of fraud" }
    ],
    "excluded": [ { "url": "https://…", "title": "…", "reason": "different person (same name)" } ]
  },
  "risk_flag": "adverse_findings",
  "headline": "Jane Doe: 1 possible sanctions match (verify); 1 matched + 0 possible adverse-media hits (fraud); 2 same-name false matches excluded.",
  "limitations": "Screening COVERAGE, not a determination. ..."
}

Search open tenders

GET /v1/tenders

Free, no key. Open tenders across Australia and New Zealand — federal, state, council, university and health. Returns {total, results[], coverage}.

Read coverage. It names which sources were searched and which returned nothing, so an empty result means “no match in what we searched” — never “no such tender exists”. There is deliberately no location filter: it is populated on a small minority of rows against jurisdiction's every row, so filtering by it would silently hide most of the corpus.

ParameterTypeDescription
qqueryFree-text search across title, summary and buyer.
jurisdictionqueryAU, NZ, or a state: AU-NSW, AU-VIC, AU-QLD, AU-WA, AU-SA, AU-TAS, AU-ACT, AU-NT.
tier, source, categoryqueryFilter by government tier, the source registry id, or category.
closing_beforequeryISO date — tenders closing on or before this day.
include_closedqueryDefault false. Open tenders only unless you ask otherwise.
limit, offsetqueryPaging.

Where the tender data comes from

GET /v1/tenders/sources

Free. Every nightly and observed snapshot source, including its compact tag, full name, URL, refresh mode, tender count, and most recent run or snapshot date. This is the honesty surface — it names the sources behind login walls and the ones that returned nothing, which is usually the answer to “why did my search come back empty?”

Rank tenders against what a company does

POST /v1/tenders/match

Reads a company's website, builds a capability profile, and ranks open tenders against it. Free. Each match carries why[] — the company's own stated capabilities the tender needs — and gaps[], what the tender asks for that their site does not mention.

score is relative fit within these results, not a probability of winning. degraded: true means scoring was unavailable and the order is keyword relevance only, with no why/gaps — present it as unjudged rather than as ranked. An unauthenticated call returns the strongest few and counts the rest in withheld; shown + withheld == matched always holds.

ParameterTypeDescription
urlbodyA company website. A bare domain is fine — myrisk.io resolves.
examplebodyInstead of a url: civil, it or facilities. Skips the site read.
jurisdiction, closing_beforebodyOptional filters, same values as the search endpoint.

Resolve a company, and check its standing

POST /v1/companies/resolve

Work out which Australian company a name or number refers to, and whether it is in good standing. Give a name (Acrux Ltd) or an identifier (ABN, ACN or NZBN). An identifier resolves directly — no name search, no ranking — so an exact number can never land on a near-miss. A name only ever produces candidates.

Read status before candidates. ambiguous means several real companies match, and selected is null on purpose — choosing between them is yours to do, with the evidence in front of you. not_found means every register answered and none has it; inconclusive means one could not be read, so absence proves nothing. coverage lists every source and how well it was read, including the ones that were blocked.

selected.asic carries registration standing from ASIC's own register. An active ABN is not the same as a registered company — they are different registers answering different questions, and a company in external administration can still hold an active ABN. Standing is republished weekly, so every answer carries as_of and age_days. It does not include directors or officeholders.

ParameterTypeDescription
querybodyA company name, or an ABN / ACN / NZBN. A number whose check digit fails is reported as a typo, not searched for as a name.
jurisdictionbodyOptional hint (AU, NZ). New Zealand identifiers currently return inconclusive — there is no NZ register adapter yet.
json
{
  "query": "AQC Dartbrook Pty Ltd",
  "parsed_as": "name",
  "status": "resolved",
  "resolved_because": "exact registered-name match, and the only one among 3 results",
  "selected": {
    "identifiers": { "abn": "46000012813", "acn": "000012813" },
    "names": { "legal": "AQC DARTBROOK PTY LTD" },
    "abn_status": "Active",
    "asic": {
      "status": "EXAD",
      "status_meaning": "External administration",
      "in_good_standing": false,
      "registered": "1923-03-01",
      "former_names": ["ANGLO COAL (DARTBROOK) PTY LTD", "THE BELLAMBI COAL CO LTD"],
      "as_of": "2026-08-24T14:53:11", "age_days": 3,
      "caveat": "ASIC publishes this file weekly, so a status can be up to 7 days behind the live register."
    }
  },
  "candidates": [ /* every match, ranked; match_quality orders them and never selects */ ],
  "coverage": [
    { "source": "abr", "status": "ok", "detail": "2 rows of 3 matches" },
    { "source": "asic", "status": "ok", "detail": "4,002,081 companies, 3d old" }
  ]
}

Inspect a document (forensics)

POST /v1/warrants

Runs the full forensic inspection — tampering signals, structure and metadata analysis, arithmetic reconciliation — and returns a warrant: the document's verification record. The warrant is a signal for review, not a fraud verdict.

ParameterTypeDescription
file | bytes_b64bodyThe document (PDF or image). URL intake is not available on this endpoint.
streamquery, boolSSE lifecycle stream — recommended for images (deep visual analysis is slower).
freshquery, boolBypass the content-hash cache and re-inspect.

Key response fields: warrant_id, risk_band (low / medium / high / insufficient / error), inspection_quality, recommended_action, summary, signals[].

Cache check

GET /v1/warrants/check?sha256={hex}

Has this exact document already been inspected? Hash the file yourself (SHA-256, lowercase hex) and check before submitting — identical bytes return the existing warrant for free.

Retrieve a warrant

GET /v1/warrants/{warrant_id}

Fetch a stored warrant by id (warrant_ + 16 hex). Add ?format=md for a human-readable Markdown report.

PDF report

GET /v1/warrants/{warrant_id}/report.pdf

An audit-ready, branded PDF scorecard of the warrant — suitable for case files.

Warrant feedback

POST /v1/warrants/{warrant_id}/feedback

Record a reviewer's verdict on a warrant: {"verdict": "up" | "down", "note": "..."}. Feedback improves the engine's precision over time.

Tool feedback

POST /v1/feedback

Thumbs up/down on a fact-check or AI-text result: {"tool": "detect_ai_text" | "verify_references", "verdict": "up" | "down"}, with optional ref and note.

Health

GET /health

Liveness and version: {"status": "ok", "engine_version": "...", ...}

Create an API key

POST /v1/keys

Free and instant, from a signed-in account (the key shares the account's weekly limit): the key is returned once, stored only as a hash, with its key_id. Up to 5 keys per account. See Authentication.

Metered usage

GET /v1/usage

Requires Authorization: Bearer stp_…. Returns your usage this week and when your weekly limit resets — read from the same store that enforces the limit.

Authentication

Metered calls need an account-owned API key (free, no card) — send it as Authorization: Bearer stp_… on any endpoint. Reads, tender browsing and the sandbox need nothing. An invalid key is a hard 401, never a silent fallback.