Billing Adapter
Billing Adapter Interface
Section titled “Billing Adapter Interface”The Billing Adapter is the pluggable boundary between the open-source Exchange and the Exchange operator’s proprietary billing system. It is the single point of custom integration per deployment.
Go Interface
Section titled “Go Interface”// BillingAdapter is the interface that Exchange operators implement to connect// their billing system to the Exchange. This is the proprietary boundary.//// Implementations must be safe for concurrent use by multiple goroutines.// All methods accept a context for timeout/cancellation propagation.type BillingAdapter interface { // Authorize checks whether the buyer can afford this transaction. // Called synchronously during ExecuteTransaction, before signing. // // Must be fast (< 30ms p99). If the billing system is slow, // consider caching recent authorizations. // // The adapter MAY reserve funds (hold) at this stage. // If the transaction fails after authorization, Release() is called. Authorize(ctx context.Context, req *BillingAuthorizeRequest) (*BillingAuthorizeResponse, error)
// Record confirms a completed transaction in the billing system. // Called after the transaction log write and URL signing succeed. // // This is best-effort from the Exchange's perspective — if it fails, // the transaction is already committed. Reconciliation catches gaps. Record(ctx context.Context, req *BillingRecordRequest) (*BillingRecordResponse, error)
// Release cancels a previously authorized hold. // Called when a transaction fails after Authorize() but before completion. Release(ctx context.Context, billingID string) error
// GetBalance returns the buyer's remaining balance or credit. // Optional — used for informational purposes only. // Return ErrNotSupported if the billing system doesn't support balance queries. GetBalance(ctx context.Context, billingRef string) (*BillingBalance, error)
// CheckSubscription checks whether the buyer has an active subscription with the tenant. // Returns subscription status including quota and unit value for financial attribution. // Used by DiscoverResources to include zero-cost subscription offers and by ExecuteTransaction // to skip per-request billing authorization for subscription-based access. CheckSubscription(ctx context.Context, billingRef string, tenantID string) (*SubscriptionStatus, error)}
// SubscriptionStatus describes the state of a buyer's subscription with a tenant.type SubscriptionStatus struct { SubscriptionID string Active bool QuotaRemaining int64 UnitValue *rampv1.Cost // for subscription_unit_value (ASC 606 prepaid drawdown)}
type BillingAuthorizeRequest struct { BillingRef string // The buyer's billing reference (handle into the operator's billing system) Amount float64 // Transaction amount Currency string // ISO 4217 OfferID string // For audit trail TenantID string // Provider tenant}
type BillingAuthorizeResponse struct { Approved bool BillingID string // Operator's billing reference (becomes billing_id in TransactionResponse) Reason string // If denied, why}
type BillingRecordRequest struct { BillingID string TransactionID string Amount float64 Currency string TenantID string BillingRef string ContentURI string Timestamp time.Time}
type BillingRecordResponse struct { Recorded bool}
type BillingBalance struct { Available float64 Currency string CreditLimit float64 // 0 = no credit}Reference Implementation: In-Memory
Section titled “Reference Implementation: In-Memory”For development, testing, and PoC deployments:
// InMemoryBillingAdapter is the reference implementation for development.// All state is in-memory — lost on restart.type InMemoryBillingAdapter struct { mu sync.RWMutex balances map[string]float64 // billing reference -> balance holds map[string]float64 // billing ID -> held amount records []BillingRecordRequest}
func NewInMemoryBillingAdapter(initialBalances map[string]float64) *InMemoryBillingAdapter { return &InMemoryBillingAdapter{ balances: initialBalances, holds: make(map[string]float64), }}
func (a *InMemoryBillingAdapter) Authorize( ctx context.Context, req *BillingAuthorizeRequest,) (*BillingAuthorizeResponse, error) { a.mu.Lock() defer a.mu.Unlock()
balance, ok := a.balances[req.BillingRef] if !ok { return &BillingAuthorizeResponse{ Approved: false, Reason: "unknown buyer", }, nil } if balance < req.Amount { return &BillingAuthorizeResponse{ Approved: false, Reason: fmt.Sprintf("insufficient balance: %.4f < %.4f", balance, req.Amount), }, nil }
billingID := fmt.Sprintf("bill-%s", ulid.Make().String()) a.balances[req.BillingRef] -= req.Amount a.holds[billingID] = req.Amount
return &BillingAuthorizeResponse{ Approved: true, BillingID: billingID, }, nil}Production Implementation: DynamoDB
Section titled “Production Implementation: DynamoDB”For AWS deployments, wrapping DynamoDB with consistent reads:
// DynamoDBBillingAdapter implements BillingAdapter using DynamoDB.// Table schema:// PK: "BUYER#<billing_ref>" SK: "BALANCE" → {available, currency, credit_limit}// PK: "BUYER#<billing_ref>" SK: "TXN#<ulid>" → {billing_id, amount, ...}// PK: "HOLD#<billing_id>" SK: "HOLD" → {amount, buyer, created_at}//// Uses DynamoDB transactions for atomic balance deduction + hold creation.type DynamoDBBillingAdapter struct { client *dynamodb.Client table string}Real Integration Example
Section titled “Real Integration Example”A Exchange operator (e.g., an existing ad-tech SSP adding resource access) would implement the interface against their existing billing system:
// ExampleSSPBillingAdapter connects to SSP Corp's internal billing API.type ExampleSSPBillingAdapter struct { httpClient *http.Client baseURL string apiKey string}
func (a *ExampleSSPBillingAdapter) Authorize( ctx context.Context, req *BillingAuthorizeRequest,) (*BillingAuthorizeResponse, error) { // Call SSP Corp's internal billing API sspReq := &ssp.AuthorizeRequest{ AccountID: mapBillingRefToAccount(req.BillingRef), Amount: req.Amount, Currency: req.Currency, Product: "content-licensing", Reference: req.OfferID, } sspResp, err := a.ssp.Authorize(ctx, sspReq) if err != nil { return nil, fmt.Errorf("ssp billing: %w", err) } return &BillingAuthorizeResponse{ Approved: sspResp.Status == "approved", BillingID: sspResp.HoldID, Reason: sspResp.DeclineReason, }, nil}This is where the consulting value lives: building custom BillingAdapter implementations for real Exchange operators.
Dispute Credits (v1.0)
Section titled “Dispute Credits (v1.0)”When the Exchange’s dispute resolution engine resolves a dispute in the agent’s favor (RESOLUTION_TYPE_CREDIT), the billing adapter must process a credit. The BillingAdapter interface does not include an explicit Credit method — credits are applied through the existing Record flow with a negative amount, or through operator-specific credit mechanisms.
For in-memory and DynamoDB implementations, credits are applied by adding the disputed amount back to the buyer’s balance. For production integrations, credits follow the operator’s standard credit/refund workflow.
Pricing Computation
Section titled “Pricing Computation”Pricing Priority
Section titled “Pricing Priority”Pricing resolution follows a priority chain. The Exchange is the price authority:
// resolvePrice returns pricing for a content path.// Priority: Exchange config overrides > catalog pricing (from RSL/ingestion) > tenant defaults.// RSL pricing is a fallback hint; the Exchange is the price authority.func (tc *TenantCatalog) resolvePrice(path string) *rampv1.Pricing { // 1. Check Exchange config overrides (price authority) if pricing, ok := tc.PricingOverrides[path]; ok { return pricing } // 2. Check catalog pricing from ingestion (RSL-derived, fallback hint) if pricing, ok := tc.URIIndex.Get(path); ok { return pricing.Pricing } // 3. Fall back to tenant config defaults return tc.DefaultPricing}The Exchange operator sets tenant-level defaults (e.g., “$0.05 per article for all content on cosmopolitan.com”) during tenant onboarding. Providers who want path-level pricing control deploy rsl.txt.
Pricing Models
Section titled “Pricing Models”PricingModel is the charging structure only. The metering basis (“per what”) is the open Pricing.unit vocabulary (tokens, pages, accesses, records, …), not a pricing model. Attribution and contribution are Obligations, not pricing models. Subscriptions are expressed via delegation/scopes (offers come back FREE), not a pricing model.
| Pricing Model | Billing Basis | Quantity Estimate Used For |
|---|---|---|
per_unit | Price per metering unit; the unit (tokens, pages, accesses, records, minutes, …) comes from Pricing.unit | unit_cost comparison + initial estimate |
flat | Fixed price per transaction, independent of quantity | unit_cost comparison |
free | No charge | Not applicable |
Unit Cost Normalization
Section titled “Unit Cost Normalization”DiscoverResources includes estimated_quantity in each offer for unit_cost (cost per unit) comparison by AI agents. The unit of measurement (tokens, pages, minutes, records, etc.) is specified by Pricing.unit. This estimate is computed from content metadata, not from actual consumption.
Estimation Formula
Section titled “Estimation Formula”estimated_quantity = word_count x 1.32 (for token-based pricing)The 1.32 multiplier is derived from empirical analysis of English-language content across common tokenizers (BPE-based: GPT-4, Claude). For non-English content, the multiplier may differ; tenant config can override.
Data Source
Section titled “Data Source”word_count comes from RSL content metadata or sitemap <content:wordcount> extensions. If unavailable, the Exchange estimates from content length: word_count ~ content_length_bytes / 5.5. For non-token metering units (unit: "pages", "minutes", "records"), the estimated_quantity is derived from content metadata specific to that unit type.
Subscription Content and Unit Cost
Section titled “Subscription Content and Unit Cost”For subscription-based access (RSL payment type=“subscription”), unit_cost is computed retrospectively:
- Offer-time estimate: subscription_price / estimated_monthly_consumed_quantity
- Actual: subscription_price / total_consumed_quantity_in_period
- The Exchange computes the estimate from historical usage or provider catalog size
Subscription Handling
Section titled “Subscription Handling”When a buyer has an active subscription with a tenant:
- DiscoverResources includes subscription offer variants (rate=0) alongside regular offers for each catalog entry. Quota enforcement is deferred to ExecuteTransaction time.
- ExecuteTransaction skips billing authorization for subscription offers. The transaction log, signed URL, and reporting obligation are still created (subscription offers still require mandatory usage reporting).
- Financial attribution: Subscription transactions include
subscription_unit_valueon the response for ASC 606 prepaid drawdown accounting. This represents the per-request rate the content would cost outside the subscription.
Currency Model (v1)
Section titled “Currency Model (v1)”v1 Exchange operates in a single currency per deployment. All offers returned by DiscoverResources are denominated in that currency. No conversion logic is needed. The currency is declared via base_currency in the Exchange Manifest (/.well-known/ramp.json), enabling Brokers to perform cross-exchange price comparison.
// ExchangeConfig includes the deployment currency.type ExchangeConfig struct { // ... Currency string // ISO 4217 — e.g., "USD". All offers use this currency. // Published as base_currency in WellKnownManifest (role=ROLE_EXCHANGE).}Rationale: Cross-currency support adds exchange rate management, settlement timing risk, and display complexity. None of this is needed for v1, where the Exchange operator sets a single operating currency.
v2 scope: Cross-currency conversion (buyer pays in EUR, provider settles in USD) is a v2 feature. It requires exchange rate feeds, conversion at transaction time, and currency-aware reconciliation.
Revenue Tracking
Section titled “Revenue Tracking”Each tenant configuration includes a revenue share percentage:
// Revenue share (not protocol-relevant, but needed for billing)RevenueSharePct float64 // Provider's share (e.g., 0.85 = 85%)Revenue tracking and reconciliation are handled through the transaction log. The BillingRecordRequest includes all fields needed for settlement: BillingID, TransactionID, Amount, Currency, TenantID, BillingRef, ContentURI, and Timestamp.
Error Hierarchy
Section titled “Error Hierarchy”All billing errors use Go sentinel error patterns with errors.Is / errors.As unwrapping. String-based error returns are prohibited.
var ( ErrBilling = errors.New("billing") ErrBillingTimeout = fmt.Errorf("%w: timeout", ErrBilling) ErrBillingDenied = fmt.Errorf("%w: denied", ErrBilling) ErrBillingBalance = fmt.Errorf("%w: insufficient balance", ErrBillingDenied))Mapping to Connect-Go Status Codes
Section titled “Mapping to Connect-Go Status Codes”func billingErrToConnect(err error) *connect.Error { switch { case errors.Is(err, ErrBillingBalance): return connect.NewError(connect.CodeResourceExhausted, err) case errors.Is(err, ErrBillingTimeout): return connect.NewError(connect.CodeUnavailable, err) case errors.Is(err, ErrBillingDenied): return connect.NewError(connect.CodePermissionDenied, err) case errors.Is(err, ErrBilling): return connect.NewError(connect.CodeInternal, err) default: return connect.NewError(connect.CodeUnknown, err) }}| Sentinel Error | Connect Code | HTTP Status |
|---|---|---|
ErrBillingBalance | CodeResourceExhausted | 429 |
ErrBillingTimeout | CodeUnavailable | 503 |
ErrBillingDenied | CodePermissionDenied | 403 |
All billing errors MUST be logged via slog.Error with structured key-value pairs before returning the Connect error.
Billing Adapter Timeout Handling
Section titled “Billing Adapter Timeout Handling”Scenario: Billing Authorize call exceeds the 30ms timeout.
Policy options (configurable per deployment):
| Policy | Behavior | Risk | When to Use |
|---|---|---|---|
| Reject (default) | Return CodeUnavailable to agent | Lost revenue | High-value content, strict accounting |
| Deferred | Authorize optimistically, record for later settlement | Potential bad debt | Low-value content, trusted buyers |
// BillingTimeoutPolicy determines behavior when billing adapter times out.type BillingTimeoutPolicy int
const ( BillingTimeoutReject BillingTimeoutPolicy = iota // Safe: reject transaction BillingTimeoutDefer // Risky: allow with deferred billing)For deferred mode, the transaction is logged with billing_status: "deferred" and a background reconciliation goroutine retries authorization when the billing system recovers. The signed URL is still issued, but the financial record is marked as unconfirmed.