Subscribe
Start a subscription, charging the first period's fee.
Source src/operations/subscribe.ts#L53subscribesrc/worker/subscriptions.ts#L68sweepDueSubscriptions
API subscribe
subscribe does three things in one transaction. It charges a buyer for the first period
of a recurring plan, grants them the SKU, and saves a
Subscription record. The
background worker renews that record every later
period.
You submit it with the buyer, the seller, the SKU, the per-period price, and the period
length in milliseconds:
const outcome = await economy.submit({
kind: 'subscribe',
idempotencyKey: 'idem_1',
actor: { kind: 'user', userId: 'usr_a' },
userId: 'usr_a',
sellerId: 'usr_s',
sku: 'club_pass',
price: toAmount('CREDIT', 50_000n),
periodMs: 2_592_000_000,
});
// → { status: "committed", transaction: { id: "txn_…", … } }curl -s https://economy.example/submit \
-H 'content-type: application/json' \
-d '{
"kind": "subscribe",
"idempotencyKey": "idem_1",
"actor": { "kind": "user", "userId": "usr_a" },
"userId": "usr_a",
"sellerId": "usr_s",
"sku": "club_pass",
"price": "CREDIT:500.00",
"periodMs": 2592000000
}'The handler bills period one only. Every period after that, the worker’s renewal sweep bills the buyer again.
Parameters
Every field below is required; subscribe carries no optional payload. The payload
fields, beyond the kind tag, are:
| Field | Type | Default | Description |
|---|---|---|---|
idempotencyKey | string | (required) | A retried submit with the same key runs at most once. See idempotency. |
actor | Principal | (required) | Who is asking. A user actor may run subscribe only for their own wallet: the buyer userId must match the actor. |
userId | string | (required) | The buyer. Must differ from sellerId. |
sellerId | string | (required) | The seller who earns the price. |
sku | string | (required) | The item or feature the subscription grants. Non-blank. |
price | Amount | (required) | Per-period charge, in CREDIT. |
periodMs | number | (required) | Period length in milliseconds. |
The price must be in CREDIT and fall inside the configured price band — 100 to
10000 credits per period by default, set by SUBSCRIPTION_PRICE_MIN_MINOR and
SUBSCRIPTION_PRICE_MAX_MINOR in the configuration.
The periodMs must be a positive integer no larger than ten 365-day years
(315360000000 ms). Anything outside those bounds is a wiring error, not a business
decline. See Preconditions below.
Returns
subscribe resolves to an Outcome.
committed: success, carrying the first-periodTransaction.duplicate: a repeat of the sameidempotencyKey, carrying the original transaction.rejected: a valid request the system declines, with one of the reason codes below.
Postings
The first-period charge posts as one balanced
Transaction. Its legs depend on how the
price splits between the buyer’s promo
grant and their spendable balance. Promo
covers as much as it can, and spendable covers the rest.
The platform fee is charged on the spendable-funded part. The buyer’s spendable is debited
that part in full. The seller’s earned
is credited the net, and REVENUE takes the
fee:
| Account | Side | Amount |
|---|---|---|
spendable(userId) | debit | spendable part |
earned(sellerId) | credit | spendable part − fee |
SYSTEM.REVENUE | credit | fee |
The promo-funded part pays the seller real earnings out of platform revenue, with the
buyer’s drawn-down grant offset against the outstanding promo float. Promo credit
never reaches the seller as promo. The fee applies only to the spendable part, matching
spend:
| Account | Side | Amount |
|---|---|---|
promo(userId) | debit | promo part |
SYSTEM.PROMO_FLOAT | credit | promo part |
SYSTEM.REVENUE | debit | promo part |
earned(sellerId) | credit | promo part |
The fee comes from feeForPrice at platformFeeBps, rounded up
to a whole credit and capped at the charge — the same call spend and every renewal make, so
the rounding is identical.
The handler grants the buyer the SKU in the same transaction, through the end of the period just billed. A rolled-back charge rolls back the grant too.
Authorization
A user actor may run subscribe only for
their own wallet: the buyer userId must match the actor. The ownership check covers the
two accounts the charge debits: userId:promo and userId:spendable.
A system or operator actor may subscribe on any buyer’s behalf. subscribe is not a
privileged-only operation.
Reason codes
subscribe returns these RejectionCode
values as a rejected outcome:
| Code | When |
|---|---|
ALREADY_SUBSCRIBED | An ACTIVE subscription already exists for the same userId, sku, and sellerId. A second one would double-bill. |
INSUFFICIENT_FUNDS | The buyer’s spendable balance can’t cover its share of the first period. |
FUNDS_IMMATURE | The spendable-funded part draws on credits still inside their maturity window — the same gate spend runs. The decline carries availableAt. |
RISK_DENIED | The charge would push the buyer past their recent-spending velocity limit. subscribe counts against the same window as spend. |
ECONOMY_PAUSED | A maintenance window is in effect, so a user actor’s subscribe is declined. The decline carries resumesAt; a system or operator actor is never paused. |
A malformed request throws instead:
| Code | When |
|---|---|
OP.MALFORMED | A buyer subscribing to themselves. |
OP.MALFORMED | A price that isn’t CREDIT, or one outside the credit band. |
OP.MALFORMED | A non-positive or oversized periodMs. |
OP.MALFORMED | A blank sku. |
Preconditions and invariants
The handler enforces these before it posts anything:
- The buyer is not the seller.
userIdmust differ fromsellerId; a self-subscription would turn the buyer’s own promo credit into payable earnings. - No active duplicate. At most one
ACTIVEsubscription exists per(userId, sku, sellerId). - Funds cover the spendable share. The pre-check declines a short balance with
INSUFFICIENT_FUNDSbefore posting; the database’s per-user non-negative constraint is the backstop.
Like every operation, the posting is balanced: debits and credits sum to zero in CREDIT.
The charge, the Subscription record, and the SKU grant all commit in one transaction.