MCP Billing Spec v0.1

Version: 0.1.0 Date: 2026-09-21 Authors: TombStone Dash LLC License: MIT Reference Implementation: noui.bot (Agent Bazaar)

SS1 Billing envelope

Source: src/app/api/bazaar/proxy/route.ts.

POST /api/bazaar/proxy requires an authenticated consumer. The request body is {tool_id|tool_name, input}: provide tool_id or tool_name, plus input. If both identifiers are supplied, tool_id takes precedence. The route forwards input as the tool arguments.

The success response is {result, meta:{tool, provider, cost_cents, cost, latency_ms, remaining_balance_cents, receipt, receipt_persisted}}. result is mcpResponse.result || mcpResponse; tool is the tool name and provider is the provider name. cost_cents is the actual cost; cost is "Free" for zero, otherwise $ followed by (actualCost / 100).toFixed(4). latency_ms is elapsed time, and remaining_balance_cents is the balance returned by the debit (bazaar_debit_balance), or (owner.balance_cents ?? 0) - actualCost on the read-modify-write fallback.

meta.receipt is the signed receipt for this call (SS3 shape, cost_microcents = cost_cents * 10_000, verify_url: "https://noui.bot/verify"), persisted in bazaar_receipts before the response is sent; free calls carry a receipt with cost_microcents: 0. If the receipt could not be persisted the call still returns 200 (the upstream call already ran and was billed), with meta.receipt: null, meta.receipt_persisted: false, and metadata.receipt_error in the usage log. There is no silent success. Idempotency. An optional Idempotency-Key header (1-128 characters) makes the billed call replay-safe. The same key with the same request body returns the stored response byte-for-byte with the header X-Bazaar-Idempotent-Replay: true, makes no upstream call, and does not debit again. The same key with a different body returns 409 IDEMPOTENCY_MISMATCH without an upstream call. Only 2xx responses are stored, so a failed upstream call may be retried with the same key. Keys are scoped per consumer and stored in bazaar_idempotency_keys (sql/008_proxy_receipts_idempotency.sql). Debit. The balance is debited after a successful upstream call through bazaar_debit_balance(p_consumer, p_cents), a conditional UPDATE … WHERE balance_cents >= p_cents, so concurrent calls cannot overdraw: a call whose debit is refused returns 402 INSUFFICIENT_FUNDS and logs insufficient_funds. When the function has not been applied yet the route falls back to the earlier read-modify-write and tags the usage log metadata.atomic_debit: false.

Success responses and upstream failure responses carry these headers:

HeaderValue
X-Bazaar-CostString(actualCost)
X-Bazaar-Providerprovider.name
X-Bazaar-LatencyString(latencyMs)
X-Bazaar-Tooltool.tool_name
X-Bazaar-Retried"true", present only after a retry
X-Bazaar-Receiptreceipt.receipt_id, present only on success with a persisted receipt
X-Bazaar-Idempotent-Replay"true", present only when the response was replayed from the Idempotency-Key store
Errors use {error:true, code, message, ...}:
CodeHTTP statusCondition and additional fields
UNAUTHORIZED401Missing, invalid, or non-consumer key; docs: "https://noui.bot/docs/bazaar"
BAD_REQUEST400Invalid JSON, or an Idempotency-Key that is empty or longer than 128 characters
VALIDATION_ERROR422Missing tool identifier or input
RATE_LIMITED429Consumer rate limit exceeded; retry_after_seconds: 60, header Retry-After: 60
NOT_FOUND404Tool not found or inactive
UNAVAILABLE503Provider inactive
INSUFFICIENT_FUNDS402Balance below positive price (checked before the call and again by the atomic debit); cost_cents, balance_cents
IDEMPOTENCY_MISMATCH409Idempotency-Key reused with a different request body; no upstream call
RECEIPT_SIGNING_UNAVAILABLE503NODE_ENV=production, RECEIPT_SIGNING_ENFORCE=true and no RECEIPT_SIGNING_SECRET; returned before any upstream call, nothing billed
PROXY_TIMEOUT504Provider timeout; tool, provider, latency_ms, cost_cents
PROVIDER_ERROR502Upstream failure, invalid JSON, or other call error; tool, provider, latency_ms, cost_cents
The upstream request is an HTTP POST containing JSON-RPC 2.0: {jsonrpc:"2.0", method:"tools/call", params:{name:tool.tool_name, arguments:input}, id:crypto.randomUUID()}. Headers are Content-Type: application/json, X-Bazaar-Consumer: owner.id, and X-Bazaar-Tool: tool.tool_name. Each attempt has a 10-second timeout. An HTTP 5xx response triggers one retry after 1 second.

The price is tool.price_cents_override ?? provider.default_price_cents ?? 0. Only status === "success" has a nonzero actual cost, and the balance is debited only when that cost is positive. The route sets success when the upstream HTTP response is successful and its body parses as JSON; it does not separately inspect JSON-RPC errors.

SS2 Metering event

Source: src/app/api/v1/bazaar/meter/route.ts, including its bazaar_usage_logs insert. Stored receipt statuses come from the CHECK constraint in sql/004_trust_layer.sql.

POST /api/v1/bazaar/meter requires an authenticated provider or consumer key. Its body fields and defaults are:
FieldRoute interpretation and default
tool_idString; required if no tool_name; takes precedence if both are provided
tool_nameString; required if no tool_id
agent_idString; body.agent_idowner.id
statusString; body.status"success"
duration_msNumber; body.duration_ms0
input_tokensNumber; body.input_tokens0
output_tokensNumber; body.output_tokens0
metadataObject; body.metadata{}
These are JavaScript truthiness defaults and TypeScript casts, not runtime type validation. The stored receipt status enum is success|error|timeout|rate_limited; the meter route itself does not validate that enum before inserting.

The route looks up an active tool. The resolved tool ID is tool?.id || toolId || "unknown"; the provider ID is tool?.provider_id || (owner.type === "provider" ? owner.id : "unknown"). Price is tool?.price_cents_override ?? tool?.bazaar_providers?.default_price_cents ?? 0.

The usage insert maps agent_id to consumer_id, includes provider_id and resolved tool_id, and records status. cost_cents is the price only for status === "success", otherwise zero. duration_ms becomes latency_ms; input_tokens and output_tokens become request_size_bytes and response_size_bytes. Stored metadata spreads the supplied metadata, then overwrites source with "meter_api" and input_tokens and output_tokens with their defaulted values.

The HTTP 201 response is {metered, tool_id, agent_id, cost_microcents, cost_cents, status, timestamp, receipt:{receipt_id, signature, verify_url}}, with metered: true and a generated ISO timestamp. cost_microcents is (status === "success" ? priceCents : 0) * 10_000. The response's cost_cents is always priceCents, including on failed calls; it can therefore differ from the usage insert's zero cost. Receipt persistence failure is logged but does not fail the meter response.

SS3 Signed receipt

Sources: src/lib/receipts.ts, the bazaar_receipts table in sql/004_trust_layer.sql, and the meter route's receipt insert and verification URL.

FieldRepresentation
receipt_idrcpt_ followed by 16 lowercase hexadecimal characters
tool_idString
tool_nameOptional string; nullable in storage
agent_idString
provider_idString
timestampISO 8601 timestamp
duration_msOptional integer; nullable in storage
cost_microcentsInteger (BIGINT in storage)
statussuccess, error, timeout, or rate_limited
input_hashOptional string; nullable in storage
output_hashOptional string; nullable in storage
signature64-character lowercase hexadecimal HMAC-SHA256 signature
verify_urlhttps://noui.bot/api/v1/bazaar/receipts/{receipt_id}, constructed by the meter route, not a table column
Canonical string: receipt_id|tool_id|agent_id|provider_id|timestamp|cost_microcents|status signReceipt joins those field values in exactly that order with |, using cost_microcents.toString(). It computes HMAC-SHA256 over that string with the signing secret and encodes the result as hex. verifyReceipt recomputes the signature and compares it with ===. Optional fields and verify_url are not signed.

Public envelope verification

POST /api/v1/verify is public and requires no account or API key. The caller supplies the full signed receipt envelope, either as a bare receipt object or wrapped in {receipt: {...}}. Verification is stateless: it reads no database and returns only the submitted receipt plus its verification outcome. The verification page accepts signed receipt JSON; it does not offer receipt-ID lookup. GET /api/v1/verify?receipt_id=... is not supported and returns HTTP 405 with an empty body.

The response is {receipt, verification:{valid, reason, checked:{canonical, algorithm:"HMAC-SHA256"}, verified_at}}. checked.canonical is built only from caller-supplied fields; it is empty when a signed field is missing or incorrectly typed. Invalid envelopes return HTTP 200 with valid:false; unparsable JSON returns HTTP 400 with {error:true, code:"BAD_REQUEST"}.

The required fields are the seven canonical fields above plus signature. Signed string fields must be nonempty and must not contain |; delimiter collisions return ambiguous_fields before signature comparison. cost_microcents must be an integer. Receipt IDs must match ^rcpt_[0-9a-f]{16,}$ and signatures must be 64 lowercase hexadecimal characters. The verifier uses a timing-safe signature comparison. Reasons are ok, missing_fields (with a missing field list), bad_receipt_id, bad_signature_format, signature_mismatch, and ambiguous_fields.

Legacy stored-receipt endpoint

The separate legacy endpoint is GET /api/v1/bazaar/receipts/{receipt_id}. As implemented in src/app/api/v1/bazaar/receipts/[receiptId]/route.ts, it returns {receipt, verification:{valid, algorithm:"HMAC-SHA256", verified_at}}. The returned receipt contains receipt_id, tool_id, tool_name, agent_id, provider_id, timestamp, duration_ms, cost_microcents, status, signature, and created_at. It does not return input_hash, output_hash, or verify_url. verified_at is an ISO timestamp.

Example receipt

The example amount is 500 microcents = 0.05 cents ($0.0005). This fixed development fixture is generated by tests/fixtures/generate-receipt-fixtures.mjs using the real signer and its committed development fallback.

{
  "receipt_id": "rcpt_0123456789abcdef",
  "tool_id": "b0771337-b070-4000-b001-000000000001",
  "tool_name": "wallet.balance",
  "agent_id": "agent_fixture",
  "provider_id": "b0771337-b070-4000-a000-000000000001",
  "timestamp": "2026-09-21T00:00:00.000Z",
  "duration_ms": 42,
  "cost_microcents": 500,
  "status": "success",
  "signature": "ebfbeca8e4fa6d83596d2e4a261c81ad0494d25f68e81be765fe60e1e19bd26b",
  "verify_url": "https://noui.bot/api/v1/bazaar/receipts/rcpt_0123456789abcdef"
}

SS4 Implementation notes

The current meter, pricing, stats, and balance routes use cost_microcents = cost_cents * 10_000 (including the corresponding per-call pricing, aggregate revenue, and balance fields). 1 cent = 10,000 microcents, consistent with public/specs/mcp-billing-v1.md. Failed metering records zero microcents as described in SS2. Existing stored receipts and their signatures are unchanged.

The meter route stores stringified token counts in input_hash and output_hash, rather than SHA-256 digests: inputTokens ? String(inputTokens) : null and outputTokens ? String(outputTokens) : null. This token-count storage behavior is unchanged.

SS5 Relationship to the v1 draft

public/specs/mcp-billing-v1.md is the broader aspirational draft. MCP Billing Spec v0.1 documents the strictly implemented subset in the current routes, receipt signer, and receipt table, including their present behavior and discrepancies; it does not imply implementation of the wider v1 draft.