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.
| Operation | Method & path | Notes |
|---|---|---|
| Get pricing | GET /api/recipients/{recipient_id}/pricing | Current config |
| Update pricing | PUT /api/recipients/{recipient_id}/pricing | Full replace of the config |
| Get budget | GET /api/recipients/{recipient_id}/budget | Budget status snapshot |
| Reset budget | POST /api/recipients/{recipient_id}/budget/reset | Zeroes spend; admin action |
| List transactions | GET /api/recipients/{recipient_id}/transactions | Filters: type, routable_type, include_summary |
| Validate expression | POST /api/pricing/validate | CEL 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
| Field | Meaning |
|---|---|
enabled | Master switch. When false, no charge is ever applied |
price_per_routable | Default fixed price per routed item |
price_by_type | Map of routable type → price, overriding the default |
price_expression | CEL expression for dynamic pricing |
currency | ISO 4217 code, default USD |
budget | Maximum spend per period (0 = unlimited) |
budget_period | daily | weekly | monthly | unlimited |
budget_spent | Output-only — spend in the current period |
budget_reset_at | Output-only — start of the current period |
The budget status response (GetBudget) adds budget_remaining,
budget_utilization (a 0.0–1.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:
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:
PriceSource | Set when |
|---|---|
fixed | price_per_routable was used |
type_map | A price_by_type entry matched |
expression | The CEL expression produced the price |
bid | The 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:
| Field | Meaning |
|---|---|
mode | fixed (default) | expression | auction |
min_price / max_price | Price bounds |
currency | Pool currency |
default_expression | Fallback 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:
- Budget filter (
NewBudgetFilter) runs duringSelect. 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.
| Operation | Method & path |
|---|---|
| Process return | POST /api/returns |
| Get return | GET /api/returns/{return_id} |
| List returns | GET /api/returns |
| Return analytics | GET /api/pools/{pool_id}/returns/analytics (period 24h | 7d | 30d | 90d) |
| List reason codes | GET /api/returns/reason-codes |
| Returns by recipient | GET /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
- One return per decision. A decision that has already been returned is rejected.
- Assigned only. The decision's outcome must be
assignedand it must have a recipient. - Window. The time since the decision was created must be within the pool's
configured window, unless
forceis set. - Credit. A credit is issued only when
request_creditis set, the decision actually charged (PriceCharged > 0), the pool hasauto_crediton, and the return is within the window. The credit uses the idempotency keyreturn:<decision_id>so a crash between crediting and recording the return cannot double-credit. - Reroute. A reroute happens only when
request_rerouteis set and the pool hasauto_rerouteon. 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:
| Field | Default | Meaning |
|---|---|---|
enabled | false | Allow returns for this pool |
window_seconds | 86400 | How long after routing a return is accepted |
reason_codes | (empty) | Allowed codes; empty accepts any |
auto_credit | false | Auto-approve credits for valid returns |
auto_reroute | false | Auto-reroute returned items |
exclude_from_reroute | true | Keep 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.
| Operation | Method & path |
|---|---|
| List reports | GET /api/reports (type consent | financial | performance | audit) |
| Get report | GET /api/reports/{report_id} |
| Export CSV | GET /api/reports/{report_id}/csv |
| Export JSON | GET /api/reports/{report_id}/json |
| Decision audit | GET /api/reports/decisions/{decision_id}/audit |
| Consent report | POST /api/reports/consent |
| Financial report | GET /api/reports/financial |
| Performance report | GET /api/reports/performance |
| Subject data export | GET /api/reports/subjects/{subject_id}/export |
- Consent report requires
start_dateandend_date; it filters bypool_idandregulation(TCPA,GDPR, orCCPA) and caninclude_exceptions. - Financial report returns
total_revenue,total_cost,total_returns, andnet_revenue, where net revenue is revenue minus returns and costs. - Performance report returns
total_routed,total_accepted,total_returned,acceptance_rate, andaverage_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_refslinking 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
| Scope | Plugin key | Key defaults |
|---|---|---|
| Recipient pricing | pricing | enabled false, currency USD, budget_period unlimited |
| Pool pricing | pricing | mode fixed (of fixed | expression | auction) |
| Pool returns | returns | enabled false, window_seconds 86400, auto_credit false, auto_reroute false, exclude_from_reroute true |
Related
Usage metering & budget policies
The durable, tenant-scoped usage plane — an append-only event stream over a closed metric catalog, bounded rollups, and per-tenant budget policies that warn, shadow-deny, or hard-deny to cap spend and volume.
The Settlement Journal
Balanced-entry money movement in integer micro-units — zero-sum enforced by the database, append-only by construction, reversible only by compensation.