Contents

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:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. A user may request only their own payout.
userIdstring(required)The seller whose earned balance is reserved.
amountAmount(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 same idempotencyKey repeated; 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. A rejected outcome 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.

AccountSideAmount
earned(userId)debitamount
SYSTEM.PAYOUT_RESERVEcreditamount

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.

CodeWhen
BELOW_MINIMUMThe amount is under payoutMinimumEarnedMinor (default 2_000_000 minor).
PAYOUT_TOO_SOONLess than payoutMinIntervalMs (default 24h) since the seller’s last request; carries retryAfter.
PAYEE_UNVERIFIEDA payee directory is configured and the seller’s identity verification isn’t CLEARED; carries the state.
INSUFFICIENT_FUNDSThe seller’s earned balance is below amount.
FUNDS_IMMATUREThe balance covers amount, but the cleared (matured) portion does not. The decline carries availableAt.
ECONOMY_PAUSEDA 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:

CodeWhen
OP.MALFORMEDThe amount isn’t CREDIT.
MONEY.INVALID_AMOUNTThe 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 amount has left the seller’s earned balance and sits in PAYOUT_RESERVE. Both legs are CREDIT and sum to zero, so the books stay balanced.
  • Exactly one Saga exists for the request, opened in RESERVED. 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_RESERVE exactly once afterward: to REVENUE on settle, or back to earned on a reversal or timeout (see the payout saga).

Caveats

  • requestPayout never pays. It reserves credits and opens the saga; the worker’s payouts sweep does the paying, later and off this path.
  • It draws matured earnings only: FUNDS_IMMATURE can decline a request the raw earned balance covers, and detail.availableAt says when the funds clear.
  • BELOW_MINIMUM declines under payoutMinimumEarnedMinor; PAYOUT_TOO_SOON declines within payoutMinIntervalMs of the user’s last request.
  • The reserve leaves PAYOUT_RESERVE exactly 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',
  };
}

See also