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. For scoped work, start an inquiry.
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 is invoked. Checks over retained inputs can be rerun. Reproducing a historical registry lookup also requires the original registry response; its digest and observation time alone are insufficient.
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 eight 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 |
spend_cap |
Does the total of an offer (unit price times quantity, exact decimals) fit within the remaining cap the caller supplied? How the spend cap check works. | offer, cap |
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.
Two more examples: required fields and a time window
Both use only inputs you send, so the same request gives the same check result. Each claim_hash below follows the recommended computation (SHA-256 of the RFC 8785 canonical JSON of the check’s input).
Does the record have the required fields? (json_schema_conformance)
The record is missing customer_id. Values are synthetic.
curl -sS https://api.tanilo.io/v1/verify-facts \
-H "content-type: application/json" \
-d '{
"subject": { "claim_hash": "sha256-768e88945d4715866cba30ff2032718b6097044764deb200acef2b8dad122b17" },
"checks": [{
"check_type": "json_schema_conformance",
"input": {
"document": { "contact_email": "pat@example.com", "request_type": "address_change" },
"schema": {
"type": "object",
"required": ["customer_id", "contact_email", "request_type"],
"properties": {
"customer_id": { "type": "string" },
"contact_email": { "type": "string" },
"request_type": { "type": "string" }
}
}
}
}]
}'
# excerpt of the signed payload
"check_results": [{
"check_type": "json_schema_conformance",
"outcome": "fail",
"recommendation": "document does not conform to the declared schema",
"failure_reason": "schema_violation",
"failure_detail": "$: missing required property \"customer_id\""
}],
"v_gate": { … "verdict": "halt" }
It checks structure only: a record that passes can still contain wrong values.
Is the timestamp inside the allowed window? (timestamp_validation)
The window is 09:00–17:00 UTC; the supplied timestamp is 18:15 UTC.
curl -sS https://api.tanilo.io/v1/verify-facts \
-H "content-type: application/json" \
-d '{
"subject": { "claim_hash": "sha256-2babb0c9714730dae68348dfe7d35440648c0281a3b5862aa61f10b64b443f55" },
"checks": [{
"check_type": "timestamp_validation",
"input": {
"timestamp": "2026-11-05T18:15:00Z",
"not_before": "2026-11-05T09:00:00Z",
"not_after": "2026-11-05T17:00:00Z"
}
}]
}'
"check_results": [{
"check_type": "timestamp_validation",
"outcome": "fail",
"recommendation": "timestamp falls outside the declared bounds",
"failure_reason": "after_upper_bound",
"failure_detail": "2026-11-05T18:15:00Z > 2026-11-05T17:00:00Z"
}],
"v_gate": { … "verdict": "halt" }
It compares the value you send with the bounds you send. It doesn’t authenticate the timestamp or where it came from.
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. This endpoint returns 422 without a receipt when a check cannot execute. That is distinct from an executed check returning indeterminate. A halt does not by itself mean the claim is false. 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. 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.