Billing & Usage

Commerce & pricing

Per-recipient pricing and budgets, returns with credits, and compliance reporting over routed decisions.

Commerce is the settled stage of the clearing lifecycle for the lead economy that ships today: what a Worker pays for the work assigned to it, and what reverses when that work is bad. It has three APIs: pricing (what a recipient pays per routed item and how much budget they have left), returns (the Warranty window — send a routed decision back for a credit and/or a reroute), and compliance (audit trails and regulatory reports over everything above). A balanced-entry Settlement journal that unifies these ships as an opt-in co-write; this page is the per-recipient pricing and returns that clear real work now.

The auction/market settlement layer — bid sessions, clearing, and settlement receipts — is documented separately at Routing strategies → Markets. This page covers the pricing, returns, and compliance APIs that page does not; the two meet only at the market linkage section below.

Two ledgers, one word

"Ledger" appears in two unrelated places. The pricing/settlement ledger described here records per-decision charges and credits inside Ductor. The wallet ledger that moves real money lives in the Stripe integration. They are distinct subsystems — do not conflate them.

All examples assume local development, where request auth is disabled and the tenant is carried by a header. For production auth see Authentication.

export BASE="http://localhost:8080"
export DUCTOR_TENANT_ID="11111111-1111-1111-1111-111111111111"

Pricing

Pricing answers a single question at routing time: what does this recipient pay to receive this routable, and can they still afford it? Configuration is per-recipient (a plugin config block keyed pricing) with an optional pool-level policy that decides when the charge lands.

Requests authorize against resource type pricing.

OperationMethod & pathNotes
Get pricingGET /api/recipients/{recipient_id}/pricingCurrent config
Update pricingPUT /api/recipients/{recipient_id}/pricingFull replace of the config
Get budgetGET /api/recipients/{recipient_id}/budgetBudget status snapshot
Reset budgetPOST /api/recipients/{recipient_id}/budget/resetZeroes spend; admin action
List transactionsGET /api/recipients/{recipient_id}/transactionsFilters: type, routable_type, include_summary
Validate expressionPOST /api/pricing/validateCEL syntax/type check, no persistence

GetTransactions accepts type = charge | credit | refund, a routable_type filter, cursor pagination, and include_summary to fold an aggregate budget summary into the response.

Configuration fields

FieldMeaning
enabledMaster switch. When false, no charge is ever applied
price_per_routableDefault fixed price per routed item
price_by_typeMap of routable type → price, overriding the default
price_expressionCEL expression for dynamic pricing
currencyISO 4217 code, default USD
budgetMaximum spend per period (0 = unlimited)
budget_perioddaily | weekly | monthly | unlimited
budget_spentOutput-only — spend in the current period
budget_reset_atOutput-only — start of the current period

The budget status response (GetBudget) adds budget_remaining, budget_utilization (a 0.01.0 fraction), next_reset_at, and a status of ok, warning, or exceeded.

Price resolution

The calculator resolves a price by strict priority — the first applicable rule wins:

no yes yes no yes no Calculate price pricing enabled? price = 0, no charge price_expression set? evaluate CEL expression type in price_by_type? use type price use price_per_routable clamp negatives to 0

So the order is expression → type map → fixed default. A disabled recipient, or one with no expression and no matching type entry and no default, prices at 0 (no charge). Negative results — including negative expression output — clamp to 0.

Every computed price carries a PriceSource recording how it was derived:

PriceSourceSet when
fixedprice_per_routable was used
type_mapA price_by_type entry matched
expressionThe CEL expression produced the price
bidThe price was set by a winning auction bid

Pricing expressions

price_expression is a CEL expression that must return a number. It sees four variables — routable, recipient, pool, and now — plus three pricing helpers: price_tier(value, tiers) (returns the price for the highest threshold <= value), min(a, b), and max(a, b).

curl -s -X POST "$BASE/api/pricing/validate" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" \
  -d '{"expression":"routable.attributes.state == \"CA\" ? 35.0 : 25.0"}'

ValidateExpression compiles the expression and confirms it returns a numeric type without persisting anything — use it before an UpdatePricing call.

Budgets

A recipient can afford a price when their budget is unlimited (budget == 0) or when budget_spent + price <= budget. Budgets reset on calendar boundaries for the configured period — a new day, ISO week, or month — and ResetBudget starts a fresh period immediately.

Pool-level policy

A pool can carry its own pricing config (plugin key pricing) that sets the pricing mode and price bounds for the whole pool:

FieldMeaning
modefixed (default) | expression | auction
min_price / max_pricePrice bounds
currencyPool currency
default_expressionFallback pricing expression

The mode controls when a charge is applied. In fixed and expression modes the pool charges on assignment. In auction mode it does not — the charge is deferred to market or ping/post close, because the final price is the winning bid rather than a pre-set value.

Enforcement & integration

Pricing plugs into routing at two points, and neither one charges money directly:

Select candidates drop recipients who can't afford the price assignment made emit durable charge intent (if eligible) settle the charge intent Routing engine Budget filter Charge-intent planner Effect-intent handler
  • Budget filter (NewBudgetFilter) runs during Select. For each candidate with pricing enabled it computes the price and drops the recipient if they cannot afford it. Recipients without pricing enabled pass through untouched.
  • Charge-intent planner (NewChargeIntentPlanner) runs after an assignment. It plans a charge only when the pool charges on assignment and the recipient's pricing is enabled and the computed price is positive. It never writes a charge — it emits a durable effect intent that is settled asynchronously by the pricing charge effect-intent handler. This split keeps charging crash-safe and idempotent.

Recipient pricing is read from the recipient's plugin config under the key pricing.

Returns

A return sends a previously-routed decision back — optionally issuing a pricing credit and/or automatically rerouting the item to a different recipient. Requests authorize against resource type returns.

OperationMethod & path
Process returnPOST /api/returns
Get returnGET /api/returns/{return_id}
List returnsGET /api/returns
Return analyticsGET /api/pools/{pool_id}/returns/analytics (period 24h | 7d | 30d | 90d)
List reason codesGET /api/returns/reason-codes
Returns by recipientGET /api/recipients/{recipient_id}/returns

A ProcessReturn request requires decision_id and reason_code; it also accepts reason_text, request_credit, request_reroute, and force (an admin override of the return window).

Return flow

yes no no yes no yes no yes no yes ProcessReturn already returned? reject decision assigned? reject pool returns enabled? reject within window or force? reject: expired reason code valid? reject create return, maybe credit, maybe reroute mark decision returned
  • One return per decision. A decision that has already been returned is rejected.
  • Assigned only. The decision's outcome must be assigned and it must have a recipient.
  • Window. The time since the decision was created must be within the pool's configured window, unless force is set.
  • Credit. A credit is issued only when request_credit is set, the decision actually charged (PriceCharged > 0), the pool has auto_credit on, and the return is within the window. The credit uses the idempotency key return:<decision_id> so a crash between crediting and recording the return cannot double-credit.
  • Reroute. A reroute happens only when request_reroute is set and the pool has auto_reroute on. It always excludes the original recipient.

Processing marks the decision returned and emits RoutableReturned, plus ReturnCreditApproved and ReturnRerouted when those actions occur.

Pool returns config

The pool's returns policy lives under plugin key returns:

FieldDefaultMeaning
enabledfalseAllow returns for this pool
window_seconds86400How long after routing a return is accepted
reason_codes(empty)Allowed codes; empty accepts any
auto_creditfalseAuto-approve credits for valid returns
auto_reroutefalseAuto-reroute returned items
exclude_from_reroutetrueKeep the returning recipient out of reroute selection

Reason codes

The standard codes are INVALID_DATA, DUPLICATE, OUT_OF_TERRITORY, QUALITY_ISSUE, CUSTOMER_REQUEST, WRONG_TYPE, CAPACITY_EXCEEDED, and OTHER. When a pool configures its own reason_codes list, only those are accepted; an empty list accepts any code.

Analytics

GetReturnAnalytics reports return_rate, breakdowns by_reason and by_recipient, credits_issued, and counts of returns within and outside the window.

Compliance

Compliance produces audit trails and regulatory reports over the commerce plane. Every endpoint authorizes against resource type compliance with action read.

OperationMethod & path
List reportsGET /api/reports (type consent | financial | performance | audit)
Get reportGET /api/reports/{report_id}
Export CSVGET /api/reports/{report_id}/csv
Export JSONGET /api/reports/{report_id}/json
Decision auditGET /api/reports/decisions/{decision_id}/audit
Consent reportPOST /api/reports/consent
Financial reportGET /api/reports/financial
Performance reportGET /api/reports/performance
Subject data exportGET /api/reports/subjects/{subject_id}/export
  • Consent report requires start_date and end_date; it filters by pool_id and regulation (TCPA, GDPR, or CCPA) and can include_exceptions.
  • Financial report returns total_revenue, total_cost, total_returns, and net_revenue, where net revenue is revenue minus returns and costs.
  • Performance report returns total_routed, total_accepted, total_returned, acceptance_rate, and average_latency_ms.
  • Subject data export is a GDPR data-subject access request — it bundles the subject's consent records, routing decisions, pricing transactions, and returns.

Market & auction linkage

When a pool prices in auction mode, the price is set by the winning bid rather than by recipient config. Three seams connect the two planes:

  • A winning bid price flows into pricing with PriceSource = bid.
  • The market settlement receipt carries pricing_usage_refs linking the settlement back to pricing usage.
  • Auction pool mode defers the charge to market close instead of applying it at assignment (ChargesOnAssignment() is false).

The durable market session, bid, and settlement plumbing lives in the routing market service and is documented in full at Routing strategies → Markets — this page does not duplicate it.

Settlement ledger vs. wallet ledger

The auction settlement pricing ledger described here (charges and credits on routing decisions) is distinct from the Stripe wallet ledger that moves real money — see Stripe integration & invoicing.

Configuration quick reference

ScopePlugin keyKey defaults
Recipient pricingpricingenabled false, currency USD, budget_period unlimited
Pool pricingpricingmode fixed (of fixed | expression | auction)
Pool returnsreturnsenabled false, window_seconds 86400, auto_credit false, auto_reroute false, exclude_from_reroute true