Skip to content

Billing Adapter

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.

// 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
}

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
}

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
}

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.


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

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 ModelBilling BasisQuantity Estimate Used For
per_unitPrice per metering unit; the unit (tokens, pages, accesses, records, minutes, …) comes from Pricing.unitunit_cost comparison + initial estimate
flatFixed price per transaction, independent of quantityunit_cost comparison
freeNo chargeNot applicable

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.

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.

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.

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

When a buyer has an active subscription with a tenant:

  1. DiscoverResources includes subscription offer variants (rate=0) alongside regular offers for each catalog entry. Quota enforcement is deferred to ExecuteTransaction time.
  2. 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).
  3. Financial attribution: Subscription transactions include subscription_unit_value on the response for ASC 606 prepaid drawdown accounting. This represents the per-request rate the content would cost outside the subscription.

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.


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.


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)
)
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 ErrorConnect CodeHTTP Status
ErrBillingBalanceCodeResourceExhausted429
ErrBillingTimeoutCodeUnavailable503
ErrBillingDeniedCodePermissionDenied403

All billing errors MUST be logged via slog.Error with structured key-value pairs before returning the Connect error.


Scenario: Billing Authorize call exceeds the 30ms timeout.

Policy options (configurable per deployment):

PolicyBehaviorRiskWhen to Use
Reject (default)Return CodeUnavailable to agentLost revenueHigh-value content, strict accounting
DeferredAuthorize optimistically, record for later settlementPotential bad debtLow-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.