Tollgate · Open source

Building auth-and-capture for AI API calls

You can't quote the price of a model call before running it, because the model decides the output length while it writes. So quote the ceiling, escrow it, run the call, and settle the real token counts afterwards.

SolidityTypeScriptEthereumAnthropic SDK

All of the code is on GitHub.

The problem: the final cost arrives after the call

We started with a simple marketplace idea: list AI services, let someone choose one, and charge for a single call. The part that looked straightforward was the price. Then we tried to tell a buyer what one call would cost before running it.

The input can be counted up front, but the model decides the output length while it writes. A flat fee works when calls are uniform and makes short calls subsidize long ones when they vary. Billing afterward tracks usage but asks the buyer to accept a number they never approved.

We wanted both properties: a maximum the buyer agrees to before the call and a final charge based on what the call used. Tollgate applies auth-and-capture to that transaction. A provider publishes a rate card, the buyer escrows the maximum, and the contract settles the actual charge from reported token counts.

The marketplace surface was the easy part. Making that maximum a real bound—and keeping one payment, one generation, and one pricing agreement aligned—drove the design below. The result is a local reference implementation with 3 AI services, one HTTP server, and an EVM contract. Model calls are deterministic and offline by default, with an opt-in path to the Anthropic API.


One real API call. The buyer confirms a 0.003254 ETH maximum, the model uses 181 of its 400 output tokens, and 0.001095 ETH becomes withdrawable by the buyer.

What we built

Three panels. One: the on-chain rate card plus a 254-token input and a 400-token ceiling sum to a 0.003254 ETH maximum. Two: the buyer escrows that amount and signs a redemption message while the server checks the funded terms. Three: the settler submits token counts, the contract charges 0.002179 ETH, and 0.001075 ETH is credited back.

We ended up separating the transaction into 4 parts:

A public rate card. Each service has a base fee, input-token rate, output-token rate, and maximum output. The contract calculates quotes and final charges from those terms.

A buyer commitment. The buyer verifies the quote independently, commits to the complete rate card, and places the maximum charge in escrow.

A metering server. It counts the input, verifies the funded terms and buyer signature, runs one model generation, and reports input and output counts.

A settlement contract. It accepts counts rather than a price, enforces the quoted bounds, divides the charge between provider and treasury, and credits the unused escrow back to the buyer.

The browser and terminal clients hold the buyer key. The metering server holds the settler key and talks to the model API and Tollgate.sol. The contract stores services, funded calls, and withdrawable balances.

The complete offline walkthrough is:

git clone https://github.com/ashnkumar/tollgate && cd tollgate
pnpm install
./scripts/walkthrough.sh

It starts a local chain, deploys the contract and its 3 services, starts the server, and opens the browser flow. No API key or wallet setup is required. USE_FAKE_MODEL=false with an ANTHROPIC_API_KEY runs the same transaction against the real API.


Lesson 1: The seller reports usage, not a price

Our first version calculated prices in the server. The arithmetic was correct, but the seller was running the meter, calculating the bill, and collecting the payment. The buyer couldn’t distinguish a correct charge from an inflated one.

We moved the rate card and arithmetic into the contract. quote() is public, so the browser and terminal clients calculate the same maximum without trusting the server. At settlement, the server submits only token counts:

// packages/contracts/contracts/Tollgate.sol

    function settleCall(bytes32 callId, uint32 inputTokens, uint32 outputTokens) external {
        Call storage c = calls[callId];
        if (c.buyer == address(0)) revert NoSuchCall();
        if (c.settled) revert AlreadySettled();

        if (msg.sender != c.settler) revert NotSettler();
        if (outputTokens > c.maxOutputTokens) revert OutputOverCap(outputTokens, c.maxOutputTokens);
        if (inputTokens > c.quotedInputTokens) revert InputOverQuote(inputTokens, c.quotedInputTokens);

The contract uses the rates stored with the funded call and rejects a cost above the escrow. A dishonest settler can’t submit an amount, report either count above its quoted limit, redirect earnings, or charge past the buyer’s maximum.

It can still lie within those bounds. Nothing on-chain can prove how many tokens a model used. The contract changes the settler’s authority from choosing the final bill to reporting 2 bounded measurements. Verifiable metering would require an oracle, trusted execution, or proof of inference.

x402’s upto scheme already supports authorizing a maximum and settling a smaller usage-based amount; its listed use cases include LLM token generation. Tollgate makes one narrower trade: the resource server reports usage counts and the contract applies a published formula, rather than the resource server sending the final amount to a facilitator.

The reusable rule isn’t specific to a chain: the party with an incentive to increase a bill should submit measurements, not the monetary conclusion.


Lesson 2: Agreeing to a total isn’t agreeing to the formula

Once the contract was calculating prices, we had the buyer compare the server’s quote with the on-chain quote. The totals matched, so we treated that as an agreement. It wasn’t.

We found the gap by constructing 2 rate cards with the same quote. The demo’s summarize service charges 0.001 ETH plus 0.000001 ETH per input token and 0.000005 ETH per output token, with a 400-token ceiling. A 254-token input quotes at 0.003254 ETH. Moving that entire amount into the base fee and setting both token rates to 0 produces the same quote, but every call now consumes the full escrow regardless of output length.

Our checks compared the service, input count, ceiling, and total. They all passed. The buyer had verified a number and funded a different formula.

We changed the funding transaction to carry the agreement itself. termsHash() hashes the service ID, provider, settler, 3 rates, and output ceiling. The buyer reads the quote and hash together, then sends both the hash and escrow to openCall():

// packages/contracts/contracts/Tollgate.sol

        bytes32 actualTerms = _termsHash(serviceId, s);
        if (actualTerms != expectedTerms) revert TermsChanged(expectedTerms, actualTerms);

If any term moves before funding lands, the transaction reverts. The buyer must take a fresh quote.

That fixed quote to funding and left a second window. Settlement still read the provider’s live service, so an ordinary rate change could make an already-funded call impossible to settle: raised rates could exceed escrow, a lower ceiling could reject the real output, and a new settler could lock out the server that performed the work. We fixed that separately by copying the complete terms into each call. termsHash() now protects quote to funding; the call snapshot protects funding to settlement.

Both commitments cover billing, not the service implementation. The model, system prompt, and input contents remain off-chain. The buyer still trusts the server to perform the advertised work; Tollgate makes the price agreement independently checkable, not the generation itself.


Lesson 3: The API provides one ceiling and one estimate

The quote needs 2 token numbers before generation: the input size and the maximum output. We first treated both as ordinary configuration values. They provide different guarantees.

For output, we considered prompt instructions and effort settings, but neither provides a numeric bound. The Messages API defines max_tokens as an absolute maximum. Generation may stop sooner but can’t exceed it, so that became the output side of the quote.

For input, we initially treated the pre-call count as the billable count. Then we read the token-counting documentation more closely. Anthropic documents count_tokens as an estimate, and the estimate can include system-added tokens that aren’t billed. Treating it as the final invoice could end up overcharging the buyer.

The clients don’t tokenize the input independently. They verify the contract’s price for the count the server returned. That makes the arithmetic public without making the measurement trustless.

Tollgate uses the pre-call count as the maximum billable input and charges the lower of the observed and quoted count:

// packages/server/src/app.ts

    const billedInputTokens = Math.min(result.inputTokens, onChainCall.quotedInputTokens);
    const billedOutputTokens = Math.min(result.outputTokens, onChainCall.maxOutputTokens);

Any divergence is the provider’s exposure. The provider chose the model and rate card; the buyer agreed to the quote.

Our first count and generation requests were also assembled separately. That made an ordinary refactor—a system prompt, tool, or message added to one path—a pricing change. We replaced both with one request builder:

// packages/server/src/ai.ts

function buildRequest(service: ServiceDefinition, input: string) {
  return {
    model: service.model,
    system: service.systemPrompt,
    messages: [{ role: "user" as const, content: input }],
    thinking: { type: "disabled" as const },
  };
}

Four opt-in live tests check the current vendor assumptions: pre-call counting, agreement between the count and the usage returned for catalog requests, output-ceiling enforcement, and usable output.


Lesson 4: One payment must start one generation

Once a call could be funded and settled correctly, we assumed one funded call would produce one model generation. It didn’t. The model call is the irreversible step: after it starts, the provider may owe the model vendor money whether settlement succeeds or not.

The first duplicate path was /run. Two requests with the same call ID could both read the pending quote, await chain checks, and reach generation before either consumed it. The buyer would settle once while the provider paid for multiple generations.

The current handler claims the call ID synchronously before its first await:

// packages/server/src/app.ts

    if (inFlight.has(callId)) throw new HttpError(409, "This call is already being run");
    inFlight.add(callId);

We wrote a concurrency test for the fix and it passed even after we removed the guard. The harness was serializing the requests before they reached the handler. The useful test needed a real listening socket and a delayed fake model so both requests could overlap.

Then we found a second duplicate path in the SDK. The TypeScript SDK retries selected connection, timeout, rate-limit, and server errors twice by default. One call in the application can become 3 model requests. max_tokens limits each attempt, not the combined spend.

Tollgate disables model retries:

// packages/server/src/ai.ts

      maxRetries: 0,
      timeout: MODEL_TIMEOUT_MS,

Model refusals arrive as normal responses, so the client checks stop_reason and turns a refusal into a full-refund path. Anthropic also offers automatic refusal fallbacks but Tollgate doesn’t enable them: a fallback is another generation on another model, while the service advertised one model and the buyer funded one generation under its ceiling.

A failed model call attempts a full on-chain refund. Chain transactions are different: by then the model cost has already been charged, so the chain client retries settlement up to 3 times. It serializes transactions from the settler key, resets the local nonce after a failed send, and checks the call’s on-chain state before retrying an ambiguous result.

This protects the provider from duplicate generations and the buyer from a settlement that mined but was reported as failed. It doesn’t make output durable. The generated text stays in memory until settlement completes; a crash or lost response after settlement can still leave the buyer charged with no output to retrieve.


Lesson 5: The ceiling that bounds cost can truncate the answer

By this point, max_tokens was carrying the whole pricing model. We initially described it as the buyer’s answer budget. That was only true while the model spent the budget on visible text.

On Claude Opus 5, thinking tokens count toward the same max_tokens budget as response text. A difficult request can consume much of the purchased budget before producing the visible answer. The cost remains bounded while the answer can still be cut short.

We decided to disable thinking so the rate card’s output ceiling applies to visible output. That is a metering decision, not the vendor’s preferred quality setting. On Claude Opus 5, disabling thinking is permitted only at high effort or below and can expose internal tags in visible text. The catalog uses the documented output-hygiene instruction:

// packages/server/src/catalog.ts

const OUTPUT_HYGIENE = "Do not include internal or system XML tags in your response.";

Anthropic’s guidance is to leave thinking enabled and lower effort when cost matters. Effort changes expected reasoning volume but doesn’t provide a strict token count, so it can’t replace the ceiling this transaction needs.

Models that require thinking can still provide a hard cost ceiling, but not a guarantee that the ceiling corresponds to visible answer tokens. Tollgate’s catalog is limited to configurations where thinking can be disabled.

That still leaves the provider choosing the ceiling. Lower values reduce escrow and increase the chance of a response ending at the limit. Higher values reduce truncation and hold more buyer capital that will later be refunded.

We selected the 400, 1200, and 2000-token demo ceilings to make different settlement outcomes visible. They weren’t tuned against a workload. A response that reaches its ceiling is fully charged whether it ended cleanly or mid-sentence; billing alone can’t distinguish the 2 cases.


What this doesn’t solve


Try it

git clone https://github.com/ashnkumar/tollgate && cd tollgate
pnpm install
./scripts/walkthrough.sh

Source: https://github.com/ashnkumar/tollgate

All projects

Let's build your next agent.

Get in touch