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) for 24 hours; after that the key is treated as new (the request runs and bills again) and old rows are purged (sql/009_idempotency_ttl_purge.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:
| Header | Value |
|---|---|
X-Bazaar-Cost | String(actualCost) |
X-Bazaar-Provider | provider.name |
X-Bazaar-Latency | String(latencyMs) |
X-Bazaar-Tool | tool.tool_name |
X-Bazaar-Retried | "true", present only after a retry |
X-Bazaar-Receipt | receipt.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, ...}:
| Code | HTTP status | Condition and additional fields |
|---|---|---|
UNAUTHORIZED | 401 | Missing, invalid, or non-consumer key; docs: "https://noui.bot/docs/bazaar" |
BAD_REQUEST | 400 | Invalid JSON, or an Idempotency-Key that is empty or longer than 128 characters |
VALIDATION_ERROR | 422 | Missing tool identifier or input |
RATE_LIMITED | 429 | Consumer rate limit exceeded; retry_after_seconds: 60, header Retry-After: 60 |
NOT_FOUND | 404 | Tool not found or inactive |
UNAVAILABLE | 503 | Provider inactive |
INSUFFICIENT_FUNDS | 402 | Balance below positive price (checked before the call and again by the atomic debit); cost_cents, balance_cents |
IDEMPOTENCY_MISMATCH | 409 | Idempotency-Key reused with a different request body; no upstream call |
RECEIPT_SIGNING_UNAVAILABLE | 503 | NODE_ENV=production, RECEIPT_SIGNING_ENFORCE=true and no RECEIPT_SIGNING_SECRET; returned before any upstream call, nothing billed |
PROXY_TIMEOUT | 504 | Provider timeout; tool, provider, latency_ms, cost_cents |
PROVIDER_ERROR | 502 | Upstream 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. Each debit also credits the provider through bazaar_credit_provider, net of the 10% platform fee applied to their running gross; a failed credit never fails the call and is recorded as metadata.provider_credit_error on the usage log. The route sets success when the upstream HTTP response is successful, its body parses as JSON, and it carries neither a JSON-RPC error nor an MCP tool failure (result.isError: true). Those two are PROVIDER_ERROR and are not charged.
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:
| Field | Route interpretation and default |
|---|---|
tool_id | String; required if no tool_name; takes precedence if both are provided |
tool_name | String; required if no tool_id |
agent_id | String; body.agent_id, or the key owner for a consumer key. Required (422) with a provider key |
status | success, error or timeout, default success (422 otherwise). A consumer key can only send success (403) |
duration_ms | Number; body.duration_ms || 0 |
input_tokens | Number; body.input_tokens || 0 |
output_tokens | Number; body.output_tokens || 0 |
metadata | Object; 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 the charged amount: priceCents on success, otherwise zero, matching the usage insert and cost_microcents. 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.
| Field | Representation |
|---|---|
receipt_id | rcpt_ followed by 16 lowercase hexadecimal characters |
tool_id | String |
tool_name | Optional string; nullable in storage |
agent_id | String |
provider_id | String |
timestamp | ISO 8601 timestamp |
duration_ms | Optional integer; nullable in storage |
cost_microcents | Integer (BIGINT in storage) |
status | success, error, timeout, or rate_limited |
input_hash | Optional string; nullable in storage |
output_hash | Optional string; nullable in storage |
signature | 64-character lowercase hexadecimal HMAC-SHA256 signature |
verify_url | https://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, ambiguous_fields, and signing_unconfigured (production has no RECEIPT_SIGNING_SECRET, so no receipt can be trusted).
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.