Request payout
Open the payout saga: reserve matured earnings for disbursement, subject to minimums and intervals.
Source src/operations/requestPayout.ts#L38requestPayoutsrc/contract.ts#L110kind 'requestPayout'src/operations/registry.ts#L62REGISTRYsrc/ports.ts#L1481-L1487SAGA_STATES
API requestPayout
requestPayout starts a seller’s cash-out. It reserves some of their earned credits and opens a payout saga that a background worker later finishes by paying real USD. It does not pay anyone itself.
Here a seller asks to cash out 25,000.00 of their earned credits, clearing the default payout minimum. The amount is their own earned balance, in CREDIT minor units:
const outcome = await economy.submit({
kind: 'requestPayout',
idempotencyKey: 'payout_2026_02',
actor: { kind: 'user', userId: 'usr_a1' },
userId: 'usr_a1',
amount: toAmount('CREDIT', 2_500_000n),
});
// → { status: "committed", transaction: { id: "txn_…", … } }curl -s https://economy.example/submit \
-H 'content-type: application/json' \
-d '{
"kind": "requestPayout",
"idempotencyKey": "payout_2026_02",
"actor": { "kind": "user", "userId": "usr_a1" },
"userId": "usr_a1",
"amount": "CREDIT:25000.00"
}'The idempotencyKey makes a retried request run at most once, so a double-tapped cash-out opens one saga, not two.
Parameters
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 may request only their own payout. |
userId | string | (required) | The seller whose earned balance is reserved. |
amount | Amount | (required) | How much earned credit to set aside. Must be CREDIT and strictly positive. |
The amount is always paid out later as USD; it never becomes spendable in-app. Only the earned account (revenue owed to a seller) is payable, so a payout drawn against spendable or promo has no meaning here.
Returns
requestPayout resolves to an Outcome.
committed: a successful request; carries the reservation transaction.duplicate: the sameidempotencyKeyrepeated; returns the earlier result unchanged.rejected: a business decline with one of the reason codes below: too small, too soon, not enough funds, or not yet matured. Arejectedoutcome is returned data, not a thrown exception.
Postings
The commit posts one balanced double-entry transaction. Both legs are CREDIT and cancel out. No USD moves here.
| Account | Side | Amount |
|---|---|---|
earned(userId) | debit | amount |
SYSTEM.PAYOUT_RESERVE | credit | amount |
This moves the credits out of the seller’s reach and into PAYOUT_RESERVE, which holds credits owed out as a payout. The USD side posts later, when the worker settles the saga. See the payout saga.
The same transaction opens a Saga in state RESERVED (see SAGA_STATES). The saga also stores the USD quote itself — the reserve converted at the request-time payout rate, floored — alongside the rate’s id. The worker submits exactly that quote to the rail and settlement posts it unchanged, so the amount can never drift between quoting, paying, and recording. A payout rate above par is refused here with CONFIG_INVALID, at the one place the payout is priced.
The committed transaction’s meta.sagaId names the saga it opened, so a caller can follow the payout with read.saga(sagaId) instead of scanning read.payouts() for its own request.
Authorization
requestPayout is not a privileged operation, so an end user can run it, but only for their own account. The ownership check requires the caller to own the earned account being debited; a user requesting another seller’s payout is refused with AUTH.UNAUTHORIZED (see debitedUserAccounts).
A system service or a human operator may also request a payout on a seller’s behalf. See actors and authorization for the full rule.
Reason codes
Each check below returns a rejected Outcome: a normal “no”, never thrown. The business checks run top to bottom, so the cheapest decline comes first. ECONOMY_PAUSED is separate: it’s a maintenance gate checked up front, before any of them.
| Code | When |
|---|---|
BELOW_MINIMUM | The amount is under payoutMinimumEarnedMinor (default 2_000_000 minor). |
PAYOUT_TOO_SOON | Less than payoutMinIntervalMs (default 24h) since the seller’s last request; carries retryAfter. |
PAYEE_UNVERIFIED | A payee directory is configured and the seller’s identity verification isn’t CLEARED; carries the state. |
INSUFFICIENT_FUNDS | The seller’s earned balance is below amount. |
FUNDS_IMMATURE | The balance covers amount, but the cleared (matured) portion does not. The decline carries availableAt. |
ECONOMY_PAUSED | A user’s request lands inside a maintenance window; carries resumesAt. |
The payee check only exists when the host composes a payee directory in — the economy-edge bridge supplies one backed by the payout rail’s hosted identity verification (the packages). Without a directory, the gate is absent.
The raw earned balance can pass INSUFFICIENT_FUNDS while part of it is still inside its chargeback window, so a second, stricter check asks whether the matured part alone covers amount (see source).
A malformed request throws instead:
| Code | When |
|---|---|
OP.MALFORMED | The amount isn’t CREDIT. |
MONEY.INVALID_AMOUNT | The amount is zero or negative (see payableCredit). |
Both are programming errors.
Preconditions and invariants
The request reserves credit; it never disburses USD. After a committed outcome:
- The reserved
amounthas left the seller’searnedbalance and sits inPAYOUT_RESERVE. Both legs areCREDITand sum to zero, so the books stay balanced. - Exactly one
Sagaexists for the request, opened inRESERVED. The reservation and the saga commit in one transaction, so a record can’t exist without its credits set aside, or the reverse. - No real USD has moved, and the credits are not spendable. They leave
PAYOUT_RESERVEexactly once afterward: toREVENUEon settle, or back toearnedon a reversal or timeout (see the payout saga).
Caveats
requestPayoutnever pays. It reserves credits and opens the saga; the worker’spayoutssweep does the paying, later and off this path.- It draws matured earnings only:
FUNDS_IMMATUREcan decline a request the rawearnedbalance covers, anddetail.availableAtsays when the funds clear. BELOW_MINIMUMdeclines underpayoutMinimumEarnedMinor;PAYOUT_TOO_SOONdeclines withinpayoutMinIntervalMsof the user’s last request.- The reserve leaves
PAYOUT_RESERVEexactly once — settle, reversal, or timeout — whatever races.
Try it
Open a real saga — the reserve moves and a card lands in RESERVED on the console’s board:
import { credits, requestPayout, userActor } from '@pwngh/economy-lab';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// A payout request reserves part of the seller's earned balance and opens a saga in
// RESERVED. From there the worker owns it: submit to the provider when due, settle or
// reverse.
export async function run(economy: Economy): Promise<SnippetReport> {
const request = await economy.submit(
requestPayout({
idempotencyKey: `idem_${crypto.randomUUID().slice(0, 8)}`,
actor: userActor('usr_nova'),
userId: 'usr_nova',
amount: credits(40), // drawn from earned revenue
}),
);
if (request.status !== 'committed') {
return {
lines: [
`requestPayout: ${request.status}` +
`${request.status === 'rejected' ? ` (${request.detail.reason})` : ''} — a gate answered first`,
],
consolePath: '/payouts',
};
}
// The transaction meta names the saga this request opened, parked until the worker's sweep.
const sagaId = request.transaction.meta.sagaId as string;
const saga = await economy.read.saga(sagaId);
const held = 'the reserve holds the credits until the worker submits';
return {
lines: [
`requestPayout: committed → ${request.transaction.id}`,
`saga ${saga?.id ?? '—'}: ${saga?.state ?? '—'} — ${held}`,
],
consolePath: '/payouts',
};
}