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 (received→workingheartbeats →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
detailmessage — 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 headerStp-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-Keyheader on a POST so a retry replays the stored response instead of processing twice (a reused key with a different body is a422). - Rate-limit headers: every response carries
RateLimit-Limit/RateLimit-Remaining/RateLimit-Resetso 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.
| Parameter | Type | Description |
|---|---|---|
| file | text | bytes_b64 | url | body | The document: multipart file, raw prose, inline base64, or a public URL. |
| deep | query, bool | Also fetch each cited source and judge support/contradiction (slower, ~a minute). |
| stream | query, bool | Return an SSE lifecycle stream instead of a single response. |
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.
curl https://www.stipple.sh/v1/fact-checks/fc_6d22fb4033cbac40Detect 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.
| Parameter | Type | Description |
|---|---|---|
| file | text | bytes_b64 | url | body | The document or prose to analyse. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| file | text | bytes_b64 | url | body | The document or prose to check. A url’s own site is excluded from matches. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| file | bytes_b64 | url | body | The document: multipart PDF/image, inline base64, or a public URL (SSRF-guarded). |
| fields | body / query | Optional ad-hoc field list — names, or {name, type, description|question}. As a query param, repeat ?fields=. |
| template | body / query | Optional preset: payslip | tax_invoice | bank_statement | receipt | contract. |
| country | body / query | Optional hint (e.g. AU, IN, US) to sharpen the read. |
| max_pages | body / query | Pages to read per document (default a few; hard ceiling 10). Also caps the charge. |
| options | body / query | Capabilities 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_scale | body / query | Rasterisation scale for small or dense print: 1.0 | 1.5 | 2.0 (default) | 3.0 | 4.0. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| files | body, multipart | One or more documents (PDF/image). Or JSON documents[] (below). |
| documents | body, JSON | Array of {"url": "…"} or {"bytes_b64": "…", "filename": "…"} (up to 10). |
| scheme | query | body | afp_100_point (default) or austrac_safe_harbour. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| files | body, multipart | One or more documents (PDF/image), up to 12. Or JSON documents[] (below). |
| documents | body, JSON | Array of {"url": "…"} or {"bytes_b64": "…", "filename": "…"}. |
| scheme | query | body | income_proof | lending_prequal | rental_application. |
| requirements | query | body | Ad-hoc checklist: type names, or {key, accepts:[types], optional}. As a query param, repeat ?requirements=. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| name | body | The subject to screen. Provide this, OR a document to read the subject from. |
| dob, country, aliases, employer, role | body | Optional disambiguators — they sharpen matching and cut same-name false positives. |
| file | bytes_b64 | url | body | Alternatively, an identity document (multipart / base64 / public URL) to read the subject from. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| q | query | Free-text search across title, summary and buyer. |
| jurisdiction | query | AU, NZ, or a state: AU-NSW, AU-VIC, AU-QLD, AU-WA, AU-SA, AU-TAS, AU-ACT, AU-NT. |
| tier, source, category | query | Filter by government tier, the source registry id, or category. |
| closing_before | query | ISO date — tenders closing on or before this day. |
| include_closed | query | Default false. Open tenders only unless you ask otherwise. |
| limit, offset | query | Paging. |
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.
| Parameter | Type | Description |
|---|---|---|
| url | body | A company website. A bare domain is fine — myrisk.io resolves. |
| example | body | Instead of a url: civil, it or facilities. Skips the site read. |
| jurisdiction, closing_before | body | Optional 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.
| Parameter | Type | Description |
|---|---|---|
| query | body | A company name, or an ABN / ACN / NZBN. A number whose check digit fails is reported as a typo, not searched for as a name. |
| jurisdiction | body | Optional hint (AU, NZ). New Zealand identifiers currently return inconclusive — there is no NZ register adapter yet. |
{
"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.
| Parameter | Type | Description |
|---|---|---|
| file | bytes_b64 | body | The document (PDF or image). URL intake is not available on this endpoint. |
| stream | query, bool | SSE lifecycle stream — recommended for images (deep visual analysis is slower). |
| fresh | query, bool | Bypass 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
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.