Tanilo
Docs
TaniloDocs

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.

POST/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 verified result 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
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.

JSON
{
  "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"
      }
    }
  ]
}

Left out here for length: the signed receipt itself (jws), the offer as echoed in the evidence, and the list of steps. The request is runnable as shown; the claim_hash in it is the SHA-256 of the canonical JSON of the input object, and you may use any hash that identifies the purchase for you.

With no terms supplied

With no terms supplied ("terms": []), the same offer gave, on the same day:

JSON
{
  "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.

JSON
{
  "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.

JSON
[
  {
    "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

ResultMeaningGate
verifiedThe offer's unit price equals the applicable term's price.act
contradictedThe offer's unit price differs from the applicable term's price. The expected price, the offered price and the difference are recorded.halt
indeterminateThe 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:

ReasonWhen
no_applicable_termNo supplied term covers this seller and product.
effective_interval_unresolvedTerms cover this seller and product, but none is in effect at the offer time.
precedence_unresolvedMore than one supplied term applies, and no declared precedence settles which. Currency and unit are not used to choose between them.
currency_mismatchThe one applicable term is priced in a different currency. No conversion is made.
unit_mismatchThe one applicable term is priced in a different unit. No conversion is made.
tier_selection_unresolvedThe 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 memberMeaning
unit_priceThe price per unit in the offer.
currencyThree-letter upper-case code, for example USD.
unitWhat one unit is, for example call or each. Compared as an exact string.
quantityHow many units. Greater than zero.
sellerYour identifier for the seller. Compared as an exact string.
skuYour identifier for the product or resource. Compared as an exact string.
offered_atThe time of the offer.
terms[] memberMeaning
agreement_id, versionWhich agreement and which version of it. Each pair may appear once.
sellerThe seller the term is with.
product_scope.skusThe products the term covers.
currency, unitWhat the term's price is expressed in.
price_per_unit or tiersExactly one of the two. tiers is a list of min_quantity, optional max_quantity and price_per_unit.
effective_from, effective_toWhen the term is in effect. effective_to is optional.
supersedesOptional. 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.

RefusedSend thisWhy
"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 tiersOne of the twoOtherwise 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.

  1. Scope. Keep terms whose seller equals the offer's seller and whose product_scope.skus contains the offer's sku. None left: no_applicable_term.
  2. Effective at the offer time. Keep terms where effective_from ≤ offered_at < effective_to. The start is included and the end is excluded. No effective_to means no end. None left: effective_interval_unresolved.
  3. 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.
  4. 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.
  5. Currency. That term's currency equals the offer's. Otherwise currency_mismatch.
  6. Unit. That term's unit equals the offer's. Otherwise unit_mismatch.
  7. Price for the quantity. A term with price_per_unit has one price. A term with tiers uses the tier where min_quantity ≤ quantity < max_quantity, and that tier's unit price applies to the whole quantity. No tier or more than one: tier_selection_unresolved.
  8. Compare. Exact decimal value: "1" equals "1.00". Equal is verified. Any difference, above or below, is contradicted.

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_to instant 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: the version of one term of the same agreement_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 an effective_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_sha256 is the SHA-256 of the RFC 8785 canonical JSON of the terms array in the order you supplied it. Member order inside an object does not matter; the order of the array does. offer_sha256 and each term_sha256 are 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:

JSON
{
  "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:

shell
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.