BIO.RE
Payout

Request a Payout

Creator requests a payout from their wallet via Stripe Connect or bank transfer. Long pre-condition gate (KYC, tax form, payout method readiness, balance, cooldown). Kill-switchable. Returns just the new payoutId.

POST /api/v1/payouts/request — 🔑 Bearer

Initiates a payout from the authenticated creator's wallet. The endpoint runs a long chain of pre-conditions before writing — KYC + tax form must be approved, the chosen payout method must be ready, the wallet must have enough headroom (balance minus any outstanding requests), and the per-creator cooldown must have elapsed. All checks happen on the request path — by the time 201 returns, the row is in the DB at PENDING.

12 pre-conditions before any DB write. This is a high-stakes financial endpoint, so the service refuses to create the PayoutRequest row until every check passes. Read the Errors table — each i18nKey corresponds to one specific check, and the order they fire is documented in Side effects. Surface the right localized message per code; "insufficient balance" is the wrong copy for "KYC required."

Kill-switchable. Class is decorated with @RequireKillSwitch('PAYOUT'). When the admin flips the kill_switch.PAYOUT config, this endpoint returns 503 (no request even reaches the service) — show "Payouts temporarily unavailable" on the user side. Same applies to GET /payouts/report — the kill switch is class-wide.

Outstanding payouts are subtracted from your available balance. Server-side aggregate of PENDING + APPROVED payouts for this creator is deducted from wallet.balance before the amount check. Two requests for $100 against a $150 wallet won't both succeed — the second sees availableBalance = $50. PROCESSING is excluded because the wallet has already been debited at that stage.

Cooldown is the effective frequency cap. payout.cooldown_days (admin-managed, default 7) — the next request after a non-REJECTED payout fails with frequency_limit until that many days elapse. There's a VelocityCheckGuard on this endpoint as well (config keys fraud.payout_window_days / fraud.max_weekly_payouts); when it rejects, the response carries error.guard.payout_limit. Show the right copy for whichever fires.

Request

Body — RequestPayoutDto

FieldTypeRequiredValidationNotes
amountstringRegex ^\d+(\.\d{1,2})?$Decimal as string (e.g. "50.00"). Server parses with Prisma.Decimal. Must be ≥ $1 (separately enforced in service).
methodenumIsEnum(PayoutMethod)STRIPE_CONNECT / BANK_TRANSFERDetermines which payout-method checks run (see Side effects steps 4–5).

Headers

HeaderRequiredNotes
Authorization: Bearer <accessToken>Global JwtAuthGuard. Creator identity comes from JWT — not the body.
Content-Type: application/jsonStandard.

Response

201 CreatedApiResponseOf<RequestPayoutResponseDto>

{
  "success": true,
  "data": {
    "payoutId": "po-uuid-..."
  }
}
FieldTypeNotes
payoutIdstring (UUID)Server-generated randomUUID(). Use it to look the request up in GET /payouts/report — the row is at status: 'PENDING' until an admin acts on it.

After 201:

  • A PayoutRequest row exists at PENDING. It will move to APPROVEDPROCESSINGPROCESSED (or FAILED / REJECTED) via admin action.
  • A fire-and-forget payout_requested notification is queued for the creator. Failures are logged, not surfaced.
  • The creator's available balance for subsequent requests is reduced by this amount (still in their wallet — but counted against future requests).

Errors

HTTPi18nKeyWhen
400payment.payout.error.kyc_requiredcreator.kycStatus !== APPROVED.
400payment.payout.error.tax_form_requiredNo TaxForm row with status = APPROVED for this creator.
400payment.payout.error.stripe_not_connectedmethod = STRIPE_CONNECT and creator has no stripeAccountId.
400payment.payout.error.stripe_not_activeStripe account exists but stripeAccountStatus !== ACTIVE.
400payment.payout.error.stripe_payouts_disabledStripe account active but stripePayoutsEnabled = false.
400payment.payout.error.bank_iban_requiredmethod = BANK_TRANSFER and creator has no iban.
400payment.payout.error.bank_holder_requiredBank transfer but no accountHolderName.
400payment.payout.error.bank_not_verifiedBank fields set but bankAccountVerified = false.
400payment.payout.error.wallet_frozenCreator wallet has frozen = true.
400payment.payout.error.wallet_in_debtWallet balance < 0. Payload: { debt } (absolute value).
400payment.payout.error.minimum_amountWallet balance below payout.min_amount config (default 10) OR request amount below $1. Payload: { minPayout }.
400payment.payout.error.insufficient_balanceamount > balance − sum(PENDING+APPROVED outstanding).
400payment.payout.error.frequency_limitLast non-REJECTED payout was less than payout.cooldown_days (default 7) ago.
400error.guard.payout_limitVelocityCheckGuard rejected. Reads fraud.payout_window_days / fraud.max_weekly_payouts. Also creates a FraudFlag row.
400(DTO validation)amount regex mismatch, method not in enum.
401(guard)Missing / invalid bearer token.
404payment.payout.error.profile_not_foundThe authenticated user has no CreatorProfile.
404payment.payout.error.wallet_not_foundCreator profile exists but the CREATOR-type wallet doesn't (data integrity issue).
503(kill switch)kill_switch.PAYOUT is engaged.

Side effects

In order — first failing check throws, no later check runs, no DB write happens until step 13:

  1. KillSwitchGuard reads kill_switch.PAYOUT from admin config; engaged → 503.
  2. JwtAuthGuard (global). userId = req.user.id.
  3. VelocityCheckGuard: count non-REJECTED payouts in the last fraud.payout_window_days; if ≥ fraud.max_weekly_payouts, create a FraudFlag row and throw 400 error.guard.payout_limit.
  4. Resolve CreatorProfile by userId404 profile_not_found.
  5. KYC check: kycStatus === APPROVED → else 400 kyc_required.
  6. Tax form check: at least one TaxForm with status = APPROVED for this creator → else 400 tax_form_required.
  7. Method-specific readiness:
    • STRIPE_CONNECT: stripeAccountId set, stripeAccountStatus = ACTIVE, stripePayoutsEnabled = true (3 separate checks, distinct error codes).
    • BANK_TRANSFER: iban set, accountHolderName set, bankAccountVerified = true (3 separate checks).
  8. Resolve CREATOR-type wallet by userId404 wallet_not_found.
  9. Wallet status: frozen === false → else 400 wallet_frozen. balance >= 0 → else 400 wallet_in_debt.
  10. Minimum balance: balance >= payout.min_amount (admin config, default 10) → else 400 minimum_amount.
  11. Minimum amount: amount >= 1 → else 400 minimum_amount.
  12. Available balance: aggregate sum(amount) of this creator's payouts in [PENDING, APPROVED] (NOT PROCESSING); compute available = balance - outstanding; require amount <= available → else 400 insufficient_balance.
  13. Cooldown: read most recent non-REJECTED payout; if (createdAt + cooldown_days) > now400 frequency_limit.
  14. Write: prisma.payoutRequest.create({ data: { id: randomUUID(), creatorId, amount: Decimal(amount), method, status: 'PENDING' } }).
  15. Fire-and-forget notification: notificationService.send({ eventKey: 'payout_requested', userId, variables: { amount, method } }) — failures logged.
  16. Increment payoutRequestedTotal Prometheus counter.
  17. Return { payoutId }.

Code samples

curl -X POST https://api.bio.re/api/v1/payouts/request \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": "50.00",
    "method": "STRIPE_CONNECT"
  }'
curl -X POST https://api.bio.re/api/v1/payouts/request \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": "100.00",
    "method": "BANK_TRANSFER"
  }'
type PayoutMethod = 'STRIPE_CONNECT' | 'BANK_TRANSFER';

async function requestPayout(
  accessToken: string,
  amount: string,                  // e.g. "50.00"
  method: PayoutMethod,
): Promise<{ payoutId: string }> {
  const res = await fetch('https://api.bio.re/api/v1/payouts/request', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ amount, method }),
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    // Discriminate per i18nKey for correct user-facing copy.
    throw Object.assign(new Error(json?.error?.message ?? 'Payout failed'), {
      code: json?.error?.code,
      i18nKey: json?.error?.i18nKey,
      status: res.status,
    });
  }
  return json.data;
}
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useRequestPayout() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (input: { amount: string; method: PayoutMethod }) => {
      const res = await fetch('/api/v1/payouts/request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Payout failed'), {
          i18nKey: json?.error?.i18nKey,
          status: res.status,
        });
      }
      return json.data as { payoutId: string };
    },
    onSuccess: () => {
      // The new PENDING payout shifts the available-balance math —
      // refetch the wallet view AND the report so both reflect reality.
      qc.invalidateQueries({ queryKey: ['payout', 'report'] });
      qc.invalidateQueries({ queryKey: ['wallet'] });
    },
    // Don't auto-retry — the user needs to see the rejection reason.
    retry: false,
  });
}

Try it

POST
/api/v1/payouts/request
AuthorizationBearer <token>

In: header

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/payouts/request" \  -H "Content-Type: application/json" \  -d '{    "amount": "50.00",    "method": "STRIPE_CONNECT"  }'
{
  "success": true,
  "data": {
    "payoutId": "c3d65312-6575-43de-b8ae-728d8d0a9371"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/payout/payout.controller.ts24–54 (class), 32–41 (requestPayout)
DTO (request)apps/api-core/src/modules/payout/dto/index.ts6–12 (RequestPayoutDto)
DTO (response)apps/api-core/src/modules/payout/dto/payout-client-response.dto.ts8–11 (RequestPayoutResponseDto)
Serviceapps/api-core/src/modules/payout/payout.service.ts32–111 (requestPayout — full pre-condition chain)
Velocity guardapps/api-core/src/common/guards/velocity-check.guard.ts19–74 (checkPayoutVelocity)
Kill switchapps/api-core/src/common/guards/kill-switch.guard.ts17–33 (@RequireKillSwitch + guard)
Config keys(admin-managed via ConfigService)payout.min_amount (default 10), payout.cooldown_days (default 7), fraud.payout_window_days (default 7), fraud.max_weekly_payouts (default 3), kill_switch.PAYOUT
Prisma modelpackages/prisma/prisma/schema.prismaPayoutRequest lines 1183–1210, PayoutStatus enum 772–781, PayoutMethod enum 783–786, CreatorProfile.kycStatus / stripeAccountStatus / iban / bankAccountVerified (315–351), Wallet (frozen/balance fields), TaxForm (status APPROVED gate)

On this page