For claims that are lookups,
not judgments.
POST /v1/verify-facts resolves a claim with a deterministic check and signs the result when signing succeeds. No LLM is invoked anywhere in the pipeline. The signed payload records the declared check mode and check types. The signature makes that declaration tamper-evident; it does not independently prove how the service executed.
Live, free during beta. POST /v1/verify-facts answers at https://api.tanilo.io, rate-limited per network. No API key is needed; each completed check returns a signed receipt when signing succeeds. Self-serve accounts are not open yet: join early access.
Why this exists
The reasonable objection to any verification service is: who checks the checker? If an LLM decides whether a claim is true, the trust chain contains a model, and the model is the weakest link in an audit.
Some claims do not need judgment. "Does this signature verify against this key" is not an opinion. "Does this package exist in PyPI at this version" is a lookup. "Is this timestamp inside this window" is arithmetic. For that class, the honest answer is to run the check and sign the result — with nothing probabilistic in the path.
What the mode claims: no LLM in the trust chain, recomputable end-to-end, same envelope and same verifier as every other Tanilo receipt.
What it does not claim: that the underlying source is correct. A deterministic check proves what was checked and what the check returned — not that a registry, a key, or a schema is itself trustworthy. It also does not make the caller's agent AI-free; the agent may well have used a model to generate the claim. The guarantee is scoped to our check.
Shipped check types
The seven check types below are what the endpoint ships today. GET https://api.tanilo.io/v1/verify-facts returns the same catalog, plus the allowlisted registries (pypi, npm, ietf_datatracker, github_repo), so this table can be cross-checked against the endpoint. The table itself is static text, not a live query.
| Check type | Resolves | Required input |
|---|---|---|
signature_verification |
Does this signature verify against this key? Ed25519 / EdDSA. | signing_input_b64u, signature_b64u, public_jwk |
hash_comparison |
Does the claimed digest match a digest computed over the content? SHA-256 / SHA-512, timing-safe compare. | claimed_digest, and content or content_b64u |
registry_lookup |
Does this entry exist in a declared authoritative registry, with matching metadata? Allowlisted registries only. | registry, identifier, optional expect |
regex_match |
Does this value match a declared pattern? | value, pattern, optional flags |
timestamp_validation |
Does this timestamp fall inside declared bounds? | timestamp, and not_before and/or not_after |
json_schema_conformance |
Does this document conform to a declared schema? Documented keyword subset; unsupported keywords are rejected, never skipped. | document, schema |
contract_price_match |
Does the unit price in an offer match the applicable term, among the terms you supply? Three results: verified, contradicted, or indeterminate with a reason. How the contract price check works. | offer, terms |
Call shape
# live
curl -sS https://api.tanilo.io/v1/verify-facts \
-H "content-type: application/json" \
-d '{
"subject": { "claim_hash": "sha256-8784833be1bfc6a9b50b217ca586785f3321ee20d3fb0ba22c9b5a14bbab6021" },
"checks": [
{
"check_type": "hash_comparison",
"input": {
"content": "the exact bytes being attested",
"claimed_digest": "sha256-8784833be1bfc6a9b50b217ca586785f3321ee20d3fb0ba22c9b5a14bbab6021"
}
},
{
"check_type": "registry_lookup",
"input": {
"registry": "pypi",
"identifier": "tanilo-receipt-verify"
}
}
]
}'
What comes back
# real response to the request above, captured 2026-10-02 (long strings shortened with …; signed by the Tanilo key)
{
"jws": { "payload": "eyJhZ2VudF9pZCI6ImRpZDphbzp2ZXJpZnktZmFj…",
"signatures": [ { "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6InRhbmlsby0y…", "signature": "Mjc26z50gHXXYSR5Y3Z1iQUBZJaG3NKDG8VoqroK…" } ] },
"canonical_sha256": "sha256-a4a5c9cb919d85c961765b84db281d5d70f6aebda…",
"canonical_bytes_length": 1521,
"verdict": "act",
"check_mode": "deterministic",
"check_types_applied": ["hash_comparison", "registry_lookup"],
"check_results": [
{ "check_type": "hash_comparison", "outcome": "pass",
"recommendation": "computed digest matches the claimed digest",
"evidence": { "algorithm": "sha256", "computed_digest": "sha256-8784833be1bfc6a9b50b217ca586785f3321ee20d…", "content_length": 30, "content_canonicalized": false } },
{ "check_type": "registry_lookup", "outcome": "pass",
"recommendation": "entry exists in the declared registry and metadata matches",
"evidence": { "registry": "pypi", "identifier": "tanilo-receipt-verify",
"observed": { "exists": true, "version": "0.1.2", "name": "tanilo-receipt-verify" },
"response_sha256": "sha256-c89804c8aca6345c178eb0cc7a5f4df540d0d6808…", "observed_at": "2026-10-02T02:47:18.122Z" } }
],
"kid": "tanilo-2026-10-ed25519-7d885da9",
"envelope_kind": "verification.v0.3+composed",
"jwks_url": "https://tanilo.io/.well-known/jwks.json",
"elapsed_ms": 20
}
Inside the signed payload, check_mode: "deterministic", check_types_applied[] and agent_id sit under the signature, so they cannot be added or removed after issue without breaking verification. agent_id is whatever the caller declared, not an authenticated identity. v_gate.confidence is 1.0 by construction and v_gate.v_adversarial_result is "n/a" — there is no judgment to contest and no probability to report.
Rejection: 422 claim_not_deterministic
If a claim cannot be resolved by a shipped check type, the endpoint refuses it. There is no fallback to the judgment path. A silent escalation to /evaluate would mean a caller who asked for a deterministic receipt could receive a model-judged one without knowing — which would make check_mode worthless as a signal.
HTTP/1.1 422 Unprocessable Entity
{
"error": "claim_not_deterministic",
"reason": "Claim cannot be resolved by any deterministic check type…",
"supported_check_types": [ … ],
"llm_fallback": false
}
Two rejection cases, deliberately distinguished
- Unresolvable — the check type is unknown, or a required input is absent. Rejected before any work runs.
- Unexecutable — the check type is shipped and the inputs are present, but the check could not actually run: an unsupported schema keyword, an unreachable registry, a malformed JWK. This is also a 422, and that matters. Signing a
halthere would assert "this claim is false" when the truth is "we could not check." A receipt must never misrepresent an inability to check as a negative finding.
A caller can move to POST /evaluate for judgment instead. That has to be an explicit decision by the caller.
Design constraints worth knowing
Registries are allowlisted, not freeform
A caller cannot pass an arbitrary URL. Beyond the obvious request-forgery surface, an open fetch would destroy recomputability: a caller could point the check at a server they control and obtain a signed receipt attesting whatever that server returned. Each lookup pins a response_sha256 and observed_at in the evidence, so the receipt stays auditable after the upstream registry changes.
Unsupported schema keywords are rejected, not ignored
json_schema_conformance ships a documented subset. A schema using keywords outside it returns 422 rather than validating against the part we understand. Silently skipping unknown keywords would issue a signed receipt attesting a check that was never performed.
One canonicalization implementation
Deterministic-mode receipts are canonicalized and signed by the same RFC 8785 JCS implementation and the same Ed25519 key as every other Tanilo receipt. There is exactly one canonicalizer in the process — a second copy would silently diverge from the reference verifiers in the conformance suite.
Verifying a deterministic receipt
Nothing changes on the verifier side. Same envelope, same published JWKS, same reference verifier.
$ pip install tanilo-receipt-verify
$ curl -s https://tanilo.io/.well-known/jwks.json -o jwks.json
$ python3 -c "import json; from tanilo_receipt_verify import verify; r = verify(json.load(open('receipt.json')), jwks_by_issuer={'https://tanilo.io/.well-known/jwks.json': json.load(open('jwks.json'))}); print(r.status)"
valid — the signature checks out against the published key, offline. Leave the keys out and the result is indeterminate, never valid.
To inspect the signed mode declaration, decode the payload and read check_mode. It is inside the signed bytes; if it says deterministic and the signature verifies, that declaration is unaltered since signing. It is Tanilo's declaration of how the check ran, not independent proof of execution.
Conformance vectors for each check type — including the rejection cases, which must never produce a receipt — ship in the receipt-spec repo so a second implementer can byte-match the deterministic path the same way the judgment path is already matched.
Availability
Deterministic mode is a capability of the service, not a separate product. POST /v1/verify-facts is live in free beta, rate-limited, no key needed. Self-serve accounts are not open yet: join early access. x402 pay-per-call is not available yet; access details will be documented before launch. Checking a signature is free and offline, as it is for every Tanilo receipt. See pricing.