Contract price check
Does this offer match the terms you supplied?
Before an agent accepts an offer, contract_price_match compares the offer's unit price with the applicable term among the terms you supply, and signs the result when signing succeeds.
/v1/verify-factsFree during the beta.POST /v1/verify-facts answers at https://api.tanilo.io, with no key, rate-limited per network.
30-second summary
- It is a check type of
POST /v1/verify-facts. No model is involved: the result is arithmetic and exact comparison, and anyone holding the same input can recompute it. - You supply the terms; Tanilo does not hold or look up your contracts.
- Three results: verified, contradicted, or indeterminate with a reason.
- A
verifiedresult says the price matches the supplied term. Whether to pay is your policy's decision. - A valid signature shows which key signed these bytes and that they have not changed since. It does not show that the supplied terms were genuine.
Example
A seller offers a call at USD 2.00. The supplied term gives USD 1.00 per call for that seller and resource.
curl -sS https://api.tanilo.io/v1/verify-facts \
-H "content-type: application/json" \
-d '{
"subject": {
"claim_hash": "sha256-e38f60d319e97a9244289b37b08483ea79802c47042fe32c005e066bc232e6cd"
},
"checks": [
{
"check_type": "contract_price_match",
"input": {
"offer": {
"unit_price": "2.00",
"currency": "USD",
"unit": "call",
"quantity": "1",
"seller": "seller.example",
"sku": "api.weather.v1",
"offered_at": "2026-10-01T12:00:00Z"
},
"terms": [
{
"agreement_id": "MSA-2026-014",
"version": "1",
"seller": "seller.example",
"product_scope": {
"skus": [
"api.weather.v1"
]
},
"currency": "USD",
"unit": "call",
"price_per_unit": "1.00",
"effective_from": "2026-01-01T00:00:00Z"
}
]
}
}
]
}'Part of the response, captured live from api.tanilo.io on 2026-10-05 and trimmed. The same check_results are inside the signed payload.
{
"verdict": "halt",
"check_mode": "deterministic",
"kid": "tanilo-2026-10-ed25519-7d885da9",
"canonical_sha256": "sha256-b088b97b343f61d9fa9372b1ad3329616ad30ffec019c1f78a1be6dbe4586d54",
"check_results": [
{
"check_type": "contract_price_match",
"outcome": "fail",
"state": "contradicted",
"failure_reason": "price_mismatch",
"recommendation": "offer price differs from the applicable term among the supplied terms",
"evidence": {
"rule_id": "contract-price-match/v0.1",
"terms_source": "caller_supplied",
"matched_term": {
"agreement_id": "MSA-2026-014",
"version": "1",
"term_sha256": "sha256-d744c281708412c0d546f42fb5a341de9c67fb788a2b3c65a79440b3a2f9cea1",
"effective_from": "2026-01-01T00:00:00Z",
"effective_to": null,
"pricing": "flat",
"tier": null
},
"expected": {
"price_per_unit": "1.00",
"currency": "USD",
"unit": "call"
},
"offered": {
"unit_price": "2.00",
"currency": "USD",
"unit": "call",
"quantity": "1"
},
"difference_per_unit": "1.00",
"offer_is": "above_term_price",
"offer_sha256": "sha256-ba0135dd6bcefba5c0d01adb116ad780fc2b334f81d9308fc080c40aad2725e7",
"terms_sha256": "sha256-3c4d8717770c9e42b9c5931ea899b218310eda14ec3b2dcf15927fabefaf3999"
}
}
]
}With no terms supplied
With no terms supplied ("terms": []), the same offer gave, on the same day:
{
"verdict": "halt",
"kid": "tanilo-2026-10-ed25519-7d885da9",
"canonical_sha256": "sha256-4b97231a381dcfaaa1295f88205a90b20484d7adc4d35d106e9081ba6210de2a",
"check_results": [
{
"check_type": "contract_price_match",
"outcome": "indeterminate",
"state": "indeterminate",
"indeterminate_reason": "no_applicable_term",
"recommendation": "the applicable price could not be established from the supplied terms"
}
]
}A term with quantity tiers
Each tier runs from min_quantity up to, but not including, max_quantity. The last tier has no upper bound. Here 99 units cost 1.20 each, 100 units cost 1.00 each, and 500 or more cost 0.80 each.
{
"agreement_id": "SUP-77",
"version": "1",
"seller": "seller.example",
"product_scope": {
"skus": [
"widget-9"
]
},
"currency": "USD",
"unit": "each",
"tiers": [
{
"min_quantity": "1",
"max_quantity": "100",
"price_per_unit": "1.20"
},
{
"min_quantity": "100",
"max_quantity": "500",
"price_per_unit": "1.00"
},
{
"min_quantity": "500",
"price_per_unit": "0.80"
}
],
"effective_from": "2026-01-01T00:00:00Z"
}An amendment
Version 2 lowers the price from 1 July and says it replaces version 1. An offer on 1 August is compared with version 2. Without "supersedes": "1", both versions would be in effect on 1 August and the result would be indeterminate with precedence_unresolved. If version 1 instead carried an effective_to of "2026-07-01T00:00:00Z", only version 2 would be in effect on 1 August and no declaration would be needed, because the two terms would never apply at the same time.
[
{
"agreement_id": "MSA-2026-014",
"version": "1",
"seller": "seller.example",
"product_scope": {
"skus": [
"api.weather.v1"
]
},
"currency": "USD",
"unit": "call",
"price_per_unit": "1.00",
"effective_from": "2026-01-01T00:00:00Z"
},
{
"agreement_id": "MSA-2026-014",
"version": "2",
"supersedes": "1",
"seller": "seller.example",
"product_scope": {
"skus": [
"api.weather.v1"
]
},
"currency": "USD",
"unit": "call",
"price_per_unit": "0.90",
"effective_from": "2026-07-01T00:00:00Z"
}
]Three results, never a guess
| Result | Meaning | Gate |
|---|---|---|
verified | The offer's unit price equals the applicable term's price. | act |
contradicted | The offer's unit price differs from the applicable term's price. The expected price, the offered price and the difference are recorded. | halt |
indeterminate | The applicable price could not be established from the supplied terms. A reason code says why. This is not a finding that the price is wrong. | halt |
In a request with several checks, each keeps its own result. An indeterminate entry is never counted as a pass and never recorded as a contradiction. The gate is act only when every check passed.
Reasons for indeterminate
Reasons for indeterminate. When several would apply, the one from the earliest step of the rule is reported, in the order of this table:
| Reason | When |
|---|---|
no_applicable_term | No supplied term covers this seller and product. |
effective_interval_unresolved | Terms cover this seller and product, but none is in effect at the offer time. |
precedence_unresolved | More than one supplied term applies, and no declared precedence settles which. Currency and unit are not used to choose between them. |
currency_mismatch | The one applicable term is priced in a different currency. No conversion is made. |
unit_mismatch | The one applicable term is priced in a different unit. No conversion is made. |
tier_selection_unresolved | The quantity falls in no tier, or in more than one tier, of the applicable term. |
What it answers, and what it does not
The question is narrow: does the unit price in this offer match the price in the applicable term, among the terms supplied with the request?
What it establishes: which supplied term applies to the offer under the published rule below, what price that term gives for the offered quantity, and whether the offer's price equals it.
What it does not establish: that the supplied terms are genuine, complete, validly executed or approved by anyone. You supply the terms; Tanilo does not hold or look up your contracts. It also makes no unit or currency conversion, applies no rounding or tolerance, calculates no tax, and does not authorize a payment. A verified result says the price matches the supplied term. Whether to pay is your policy's decision.
You translate the agreement into the model. A term here is either one price per unit, or prices selected by quantity. Turning a contract into that shape is your step, and the check works only on what you send. It cannot detect pricing that the input leaves out: a discount, a rebate or a minimum charge that is never mentioned is invisible to it. With tiers, the selected tier's unit price applies to the whole quantity. It is not a marginal or cumulative scheme where the first units are priced one way and later units another.
Input
Every amount and quantity is a decimal string such as "1.00". JSON numbers are refused, so no value is ever rounded on the way in. Every time is an RFC 3339 UTC timestamp ending in Z. A date with no time is refused, because its instant would have to be guessed. A time with an explicit offset is refused because this schema is Z-only.
offer member | Meaning |
|---|---|
unit_price | The price per unit in the offer. |
currency | Three-letter upper-case code, for example USD. |
unit | What one unit is, for example call or each. Compared as an exact string. |
quantity | How many units. Greater than zero. |
seller | Your identifier for the seller. Compared as an exact string. |
sku | Your identifier for the product or resource. Compared as an exact string. |
offered_at | The time of the offer. |
terms[] member | Meaning |
|---|---|
agreement_id, version | Which agreement and which version of it. Each pair may appear once. |
seller | The seller the term is with. |
product_scope.skus | The products the term covers. |
currency, unit | What the term's price is expressed in. |
price_per_unit or tiers | Exactly one of the two. tiers is a list of min_quantity, optional max_quantity and price_per_unit. |
effective_from, effective_to | When the term is in effect. effective_to is optional. |
supersedes | Optional. The version of the same agreement that this term replaces. |
Write it this way
These are the mistakes that get a request refused. Each refusal names the member at fault, says what was received, and carries a complete valid example.
| Refused | Send this | Why |
|---|---|---|
"unit_price": 2.00 | "unit_price": "2.00" | Amounts and quantities go in quotes. A JSON number can be rounded before it arrives. |
"$2.00", "1,000.00", "1e3" | "2.00", "1000.00" | Digits and at most one point. The currency goes in currency. |
"2026-01-01" | "2026-01-01T00:00:00Z" | A full UTC time. A bare date does not say which instant. |
"2026-10-01T14:00:00+02:00" | "2026-10-01T12:00:00Z" | An explicit offset is unsupported by this Z-only schema. Convert to UTC yourself and end with Z. |
"usd" | "USD" | Upper-case three-letter code. |
"discount_percent": "10" | Not supported directly; do not omit a material discount to obtain a match. | Members that are not listed are refused, not ignored. Discounts are not in this version. |
A term with both price_per_unit and tiers | One of the two | Otherwise the term has two prices. |
One thing is not refused but matters: seller, sku and unit are compared as exact strings. "Call" in the offer and "call" in the term gives indeterminate with unit_mismatch. Use the same spelling in both.
Objects are closed: a member that is not listed above is refused. Nothing you send, such as a discount or a surcharge, is silently ignored. Limits: 200 terms per request, 50 tiers per term, 500 products per term, 18 integer digits and 8 decimal places per amount.
The rule, in order
Rule contract-price-match/v0.1. Each step narrows the supplied terms. The steps and the terms left after each one are written into the signed receipt.
- Scope. Keep terms whose
sellerequals the offer's seller and whoseproduct_scope.skuscontains the offer'ssku. None left:no_applicable_term. - Effective at the offer time. Keep terms where
effective_from≤offered_at<effective_to. The start is included and the end is excluded. Noeffective_tomeans no end. None left:effective_interval_unresolved. - Declared precedence. Set a term aside when another term of the same agreement, itself in effect at the offer time, names its version in
supersedes. Precedence is never inferred from version numbers or dates. - Exactly one term. More than one left:
precedence_unresolved. This holds even when they give the same price, and even when only one of them is in the offer's currency or unit. - Currency. That term's currency equals the offer's. Otherwise
currency_mismatch. - Unit. That term's unit equals the offer's. Otherwise
unit_mismatch. - Price for the quantity. A term with
price_per_unithas one price. A term withtiersuses the tier wheremin_quantity≤ quantity <max_quantity, and that tier's unit price applies to the whole quantity. No tier or more than one:tier_selection_unresolved. - Compare. Exact decimal value:
"1"equals"1.00". Equal isverified. Any difference, above or below, iscontradicted.
The first step that cannot continue decides the reason. Later steps are not run.
Details that decide edge cases
- Boundaries. A quantity of exactly 100 falls in a tier that starts at 100, not in one that ends at 100. An offer made at the exact
effective_toinstant is outside the term. - Time precision. Times carry seconds and, optionally, 1 to 3 fractional digits, and are compared to the millisecond.
"2026-10-01T12:00:00Z"and"2026-10-01T12:00:00.000Z"are the same instant. More than three fractional digits, or no seconds, is refused. supersedes. A single string: theversionof one term of the sameagreement_id. A term can replace one version, not several. It has effect only while the superseding term is itself in effect at the offer time. When a superseding term has ended, the term it replaced applies again if it is still in effect.- Chains. Version 3 replacing version 2, and version 2 replacing version 1, leaves version 3 when all three are in effect. Precedence is not carried across a term that is not in effect: if version 2 has ended, versions 1 and 3 are both left, and the result is
precedence_unresolved. To avoid that, give version 1 aneffective_to. - Terms that never overlap. Two terms whose dates do not both contain the offer time never compete. Nothing replaces anything in that case; only the term in effect is considered.
- Hashes.
terms_sha256is the SHA-256 of the RFC 8785 canonical JSON of thetermsarray in the order you supplied it. Member order inside an object does not matter; the order of the array does.offer_sha256and eachterm_sha256are over the canonical JSON of that object.
What the receipt records
- The result, and the reason when there is one.
- The matched term: agreement, version, effective dates, the tier used, and the SHA-256 of the term as you supplied it.
- The expected price and the offered price, with currency and unit, and the difference when they differ.
- The rule identifier and each step applied, with the terms left after each step.
- The offer as supplied, its SHA-256, and the SHA-256 of the whole set of supplied terms.
terms_source: "caller_supplied".
What the receipt discloses. The receipt carries the offer, the selected term's metadata and price, and the agreement id, version and hash of candidate terms that appear in the step trace. Full unselected term objects are not embedded. Some indeterminate results also carry, for the terms or tiers that could not be told apart, their effective dates, currency and unit, or the bounds and prices of the matching tiers.
Prices are in clear. Anyone you give the receipt to can read what is in it, so share it with that in mind. An option that records a hash in place of the price is not available in this version.
Input that cannot be read: 422, no receipt
One or more checks could not execute because the input did not satisfy the check's schema. This route issues no receipt for schema-invalid requests. A valid-input indeterminate result is different: it records that the check ran but could not establish an applicable price.
Examples of schema-invalid input: a price sent as a JSON number, a date with no time, an unknown member, a term with both a price and tiers, the same agreement version twice, or terms that say they replace one another. The answer is 422 claim_not_deterministic.
The response names the member at fault, says what was received and what to send, and carries a complete valid example. For a price sent as the number 2:
{
"error": "claim_not_deterministic",
"unexecutable": [
{
"index": 0,
"check_type": "contract_price_match",
"reason": "invalid_input",
"member": "offer.unit_price",
"detail": "offer.unit_price: must be a decimal number written as a string, in quotes, for example \"1.00\"; got the JSON number 2. JSON numbers are refused so that no amount is ever rounded."
}
],
"contract_price_match_help": {
"docs": "https://tanilo.io/docs/contract-price-check",
"amounts": "Every price and quantity is a decimal number in quotes, for example \"1.00\".",
"times": "Every time is UTC and ends in Z, for example \"2026-10-01T00:00:00Z\".",
"example_input": "{ … the example request shown above … }"
},
"llm_fallback": false
}When nothing can be signed: 503
A result is returned as a signed receipt when signing succeeds. If the service holds no signing key at that moment, it answers 503 with "status": "not_signed", the reason, and the check results. Nothing in that answer is a receipt. Treat it as "no receipt was issued" and try again later.
Checking the receipt, and replaying the check
The signature is checked the same way as for every Tanilo receipt, offline, against the published key set:
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)"A valid signature shows which key signed these bytes and that they have not changed since. It does not show that the supplied terms were genuine.
To replay the check itself, run the same rule on the offer and terms you kept. The rule's code, its test suite and its vectors are public, in tanilo-receipt-spec/checks/contract-price-match, and need only Node. The result's evidence equals the evidence in the receipt when the input and the rule are the same. offer_sha256 and terms_sha256 in the receipt (SHA-256 over RFC 8785 canonical JSON) let you confirm first that what you kept is what was checked.
Not in this version
Unit conversion, currency conversion, rounding rules, tolerances, discounts, rebates, cumulative or graduated tiers, tax, and product scopes other than a list of identifiers. Explicit unsupported fields are refused; omitted pricing semantics may be undetectable.
Availability
POST /v1/verify-facts is free during the beta, with no key, rate-limited per network. Self-serve accounts are not open yet: join early access. x402 pay-per-call is not available. See pricing and deterministic mode.