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
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
amount | string | ✓ | Regex ^\d+(\.\d{1,2})?$ | Decimal as string (e.g. "50.00"). Server parses with Prisma.Decimal. Must be ≥ $1 (separately enforced in service). |
method | enum | ✓ | IsEnum(PayoutMethod) — STRIPE_CONNECT / BANK_TRANSFER | Determines which payout-method checks run (see Side effects steps 4–5). |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. Creator identity comes from JWT — not the body. |
Content-Type: application/json | ✓ | Standard. |
Response
201 Created — ApiResponseOf<RequestPayoutResponseDto>
{
"success": true,
"data": {
"payoutId": "po-uuid-..."
}
}| Field | Type | Notes |
|---|---|---|
payoutId | string (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
PayoutRequestrow exists atPENDING. It will move toAPPROVED→PROCESSING→PROCESSED(orFAILED/REJECTED) via admin action. - A fire-and-forget
payout_requestednotification 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
| HTTP | i18nKey | When |
|---|---|---|
400 | payment.payout.error.kyc_required | creator.kycStatus !== APPROVED. |
400 | payment.payout.error.tax_form_required | No TaxForm row with status = APPROVED for this creator. |
400 | payment.payout.error.stripe_not_connected | method = STRIPE_CONNECT and creator has no stripeAccountId. |
400 | payment.payout.error.stripe_not_active | Stripe account exists but stripeAccountStatus !== ACTIVE. |
400 | payment.payout.error.stripe_payouts_disabled | Stripe account active but stripePayoutsEnabled = false. |
400 | payment.payout.error.bank_iban_required | method = BANK_TRANSFER and creator has no iban. |
400 | payment.payout.error.bank_holder_required | Bank transfer but no accountHolderName. |
400 | payment.payout.error.bank_not_verified | Bank fields set but bankAccountVerified = false. |
400 | payment.payout.error.wallet_frozen | Creator wallet has frozen = true. |
400 | payment.payout.error.wallet_in_debt | Wallet balance < 0. Payload: { debt } (absolute value). |
400 | payment.payout.error.minimum_amount | Wallet balance below payout.min_amount config (default 10) OR request amount below $1. Payload: { minPayout }. |
400 | payment.payout.error.insufficient_balance | amount > balance − sum(PENDING+APPROVED outstanding). |
400 | payment.payout.error.frequency_limit | Last non-REJECTED payout was less than payout.cooldown_days (default 7) ago. |
400 | error.guard.payout_limit | VelocityCheckGuard 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. |
404 | payment.payout.error.profile_not_found | The authenticated user has no CreatorProfile. |
404 | payment.payout.error.wallet_not_found | Creator 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:
KillSwitchGuardreadskill_switch.PAYOUTfrom admin config; engaged →503.JwtAuthGuard(global).userId = req.user.id.VelocityCheckGuard: count non-REJECTEDpayouts in the lastfraud.payout_window_days; if ≥fraud.max_weekly_payouts, create aFraudFlagrow and throw400 error.guard.payout_limit.- Resolve
CreatorProfilebyuserId→404 profile_not_found. - KYC check:
kycStatus === APPROVED→ else400 kyc_required. - Tax form check: at least one
TaxFormwithstatus = APPROVEDfor this creator → else400 tax_form_required. - Method-specific readiness:
STRIPE_CONNECT:stripeAccountIdset,stripeAccountStatus = ACTIVE,stripePayoutsEnabled = true(3 separate checks, distinct error codes).BANK_TRANSFER:ibanset,accountHolderNameset,bankAccountVerified = true(3 separate checks).
- Resolve
CREATOR-type wallet byuserId→404 wallet_not_found. - Wallet status:
frozen === false→ else400 wallet_frozen.balance >= 0→ else400 wallet_in_debt. - Minimum balance:
balance >= payout.min_amount(admin config, default10) → else400 minimum_amount. - Minimum amount:
amount >= 1→ else400 minimum_amount. - Available balance: aggregate
sum(amount)of this creator's payouts in[PENDING, APPROVED](NOTPROCESSING); computeavailable = balance - outstanding; requireamount <= available→ else400 insufficient_balance. - Cooldown: read most recent non-
REJECTEDpayout; if(createdAt + cooldown_days) > now→400 frequency_limit. - Write:
prisma.payoutRequest.create({ data: { id: randomUUID(), creatorId, amount: Decimal(amount), method, status: 'PENDING' } }). - Fire-and-forget notification:
notificationService.send({ eventKey: 'payout_requested', userId, variables: { amount, method } })— failures logged. - Increment
payoutRequestedTotalPrometheus counter. - 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
Authorization
bearer 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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/payout/payout.controller.ts | 24–54 (class), 32–41 (requestPayout) |
| DTO (request) | apps/api-core/src/modules/payout/dto/index.ts | 6–12 (RequestPayoutDto) |
| DTO (response) | apps/api-core/src/modules/payout/dto/payout-client-response.dto.ts | 8–11 (RequestPayoutResponseDto) |
| Service | apps/api-core/src/modules/payout/payout.service.ts | 32–111 (requestPayout — full pre-condition chain) |
| Velocity guard | apps/api-core/src/common/guards/velocity-check.guard.ts | 19–74 (checkPayoutVelocity) |
| Kill switch | apps/api-core/src/common/guards/kill-switch.guard.ts | 17–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 model | packages/prisma/prisma/schema.prisma | PayoutRequest 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) |
Get Wallet Activity
Paginated wallet transaction feed with optional type filter. Each row carries balance-before / balance-after for an audit-friendly trail. No wallet → empty page.
Get Payout Report
Creator-scoped payout history. Optional month filter (YYYY-MM, validated). Returns up to 500 most-recent rows, total amount, and count.