Get Payout Report
Creator-scoped payout history. Optional month filter (YYYY-MM, validated). Returns up to 500 most-recent rows, total amount, and count.
GET /api/v1/payouts/report — 🔑 Bearer
Returns the creator's own payout history for the lifetime of the account or for a specific month. The response includes the raw rows (newest first, capped at 500), a sum of amounts as a number, and a count of rows returned. Every payout — regardless of status (PENDING / APPROVED / PROCESSING / PROCESSED / FAILED / CANCELLED / REJECTED) — is included in total.
total sums ALL statuses, not just successfully paid out. The aggregate is payouts.reduce((sum, p) => sum + p.amount, 0) over the unfiltered result set — REJECTED and CANCELLED rows count toward the sum. If you want "actually paid out," filter by status === 'PROCESSED' client-side and sum yourself.
Hard cap of 500 rows. take: 500 — no pagination. If a creator has more than 500 payouts in the queried window, the older rows are silently dropped. In practice this is unlikely for an individual creator, but for ?month= queries you'll get every row in the month up to 500.
month is strictly validated. Format must match ^\d{4}-(0[1-9]|1[0-2])$ — e.g. 2026-04. Anything else (?month=2026/04, ?month=2026-13, ?month=April) → 400 payment.payout.error.invalid_month. Without ?month, the report covers all-time.
Kill-switchable. Class is decorated with @RequireKillSwitch('PAYOUT') — when admin engages the kill_switch.PAYOUT config, this read endpoint returns 503 too (the switch is class-wide, not per-handler).
Request
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
month | string | optional | YYYY-MM format (e.g. 2026-04). Strictly validated server-side; mismatch → 400. Filters createdAt >= 2026-04-01 AND < 2026-05-01. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. Creator identity comes from JWT. |
Response
200 OK — ApiResponseOf<PayoutReportResponseDto>
{
"success": true,
"data": {
"payouts": [
{
"id": "po-uuid-1",
"creatorId": "cr-uuid-1",
"amount": "50.00",
"method": "STRIPE_CONNECT",
"status": "PROCESSED",
"createdAt": "2026-04-20T09:00:00.000Z",
"approvedAt": "2026-04-20T11:00:00.000Z",
"processedAt": "2026-04-21T08:00:00.000Z",
"stripePayoutId": "po_1ABC...",
"commissionRate": 20,
"commissionAmount": "10.00",
"netAmount": "40.00"
}
],
"total": 50,
"count": 1
}
}Top-level fields
| Field | Type | Notes |
|---|---|---|
payouts | array | Up to 500 rows, newest first. PayoutRequest shape (see below). |
total | number | Sum of payouts[].amount as JS number (via Decimal.toNumber()). Sums every status — see warn callout. |
count | number | payouts.length. Capped at 500 — equals length, not "total matching rows in DB." |
Item fields (payouts[])
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | PayoutRequest.id. |
creatorId | string (UUID) | CreatorProfile.id — always your own. |
amount | string | Decimal(10, 2) serialized as a string (e.g. "50.00"). Parse with Number() or a decimal lib. |
method | enum | STRIPE_CONNECT / BANK_TRANSFER. |
status | enum | PENDING / APPROVED / PROCESSING / PROCESSED / FAILED / CANCELLED / REJECTED. |
createdAt | string (ISO 8601) | When the request was made. |
approvedAt | string (ISO 8601) | null | When admin approved. null for PENDING / REJECTED. |
processedAt | string (ISO 8601) | null | When the actual payout cleared. null until PROCESSED. |
stripePayoutId | string | null | Stripe payout id (only for STRIPE_CONNECT payouts that reached PROCESSING+). |
commissionRate | number | null | Commission percentage applied (e.g. 20 = 20%). Persisted at approval time for audit. |
commissionAmount | string | null | Decimal(10, 2) as string — commission deducted. |
netAmount | string | null | Decimal(10, 2) as string — amount - commissionAmount. |
Errors
| HTTP | i18nKey | When |
|---|---|---|
400 | payment.payout.error.invalid_month | ?month=... doesn't match `^\d4-(0[1-9] |
401 | (guard) | Missing / invalid bearer token. |
404 | payment.payout.error.profile_not_found | The authenticated user has no CreatorProfile (controller resolves this before calling the service). |
503 | (kill switch) | kill_switch.PAYOUT is engaged. |
Side effects
KillSwitchGuard(PAYOUTsystem); engaged →503.JwtAuthGuard(global).userId = req.user.id.- Controller resolves the creator:
prisma.creatorProfile.findUnique({ where: { userId } }). Missing →404 profile_not_found. - Service
getPayoutReport(creator.id, month?):- If
monthset, validate regex; buildwhere.createdAt = { gte: firstDayOfMonth, lt: firstDayOfNextMonth }. (Boundaries computed in the API server's local time zone vianew Date(year, m - 1, 1).) prisma.payoutRequest.findMany({ where, orderBy: { createdAt: 'desc' }, take: 500 }).total = sum(amount)asDecimal.toNumber().
- If
- Return
{ payouts, total, count: payouts.length }. No mutations.
Code samples
curl https://api.bio.re/api/v1/payouts/report \
-H "Authorization: Bearer $ACCESS_TOKEN"curl 'https://api.bio.re/api/v1/payouts/report?month=2026-04' \
-H "Authorization: Bearer $ACCESS_TOKEN"type PayoutStatus =
| 'PENDING' | 'APPROVED' | 'PROCESSING' | 'PROCESSED'
| 'FAILED' | 'CANCELLED' | 'REJECTED';
type PayoutReportItem = {
id: string;
creatorId: string;
amount: string; // Decimal as string
method: 'STRIPE_CONNECT' | 'BANK_TRANSFER';
status: PayoutStatus;
createdAt: string;
approvedAt: string | null;
processedAt: string | null;
stripePayoutId: string | null;
commissionRate: number | null;
commissionAmount: string | null;
netAmount: string | null;
};
type PayoutReport = {
payouts: PayoutReportItem[];
total: number; // sums ALL statuses
count: number;
};
async function getPayoutReport(
accessToken: string,
month?: string, // YYYY-MM
): Promise<PayoutReport> {
const url = new URL('https://api.bio.re/api/v1/payouts/report');
if (month) url.searchParams.set('month', month);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Report failed'), {
code: json?.error?.code,
});
}
return json.data;
}
// "Actually paid out" — server's `total` includes rejected/cancelled, so derive yourself:
function actuallyPaid(report: PayoutReport): number {
return report.payouts
.filter(p => p.status === 'PROCESSED')
.reduce((s, p) => s + Number(p.netAmount ?? p.amount), 0);
}import { useQuery } from '@tanstack/react-query';
export const payoutKeys = {
report: (month?: string) => ['payout', 'report', month ?? 'all'] as const,
};
export function usePayoutReport(month?: string) {
return useQuery({
queryKey: payoutKeys.report(month),
queryFn: async () => {
const url = new URL('/api/v1/payouts/report', location.origin);
if (month) url.searchParams.set('month', month);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Report failed'), {
code: json?.error?.code,
});
}
return json.data as PayoutReport;
},
staleTime: 60_000,
});
}Try it
Authorization
bearer In: header
Query Parameters
Month in YYYY-MM format
Response Body
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/payouts/report"{
"success": true,
"data": {
"payouts": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"creatorId": "688ebf54-d343-4104-8711-82c2feac534a",
"amount": "50.00",
"method": "STRIPE_CONNECT",
"status": "PENDING",
"createdAt": "2019-08-24T14:15:22Z",
"approvedAt": "2019-08-24T14:15:22Z",
"processedAt": "2019-08-24T14:15:22Z",
"stripePayoutId": "string",
"commissionRate": 0,
"commissionAmount": "10.00",
"netAmount": "40.00"
}
],
"total": 42,
"count": 10
}
}{
"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 | 43–53 (getReport — creator lookup + month forwarding) |
| DTO (response) | apps/api-core/src/modules/payout/dto/payout-client-response.dto.ts | 15–65 (PayoutReportResponseDto, PayoutReportItemDto) |
| Service | apps/api-core/src/modules/payout/payout.service.ts | 524–537 (getPayoutReport — month regex validation, 500-row cap, total sum) |
| Kill switch | apps/api-core/src/common/guards/kill-switch.guard.ts | 17–33 (@RequireKillSwitch) |
| Prisma model | packages/prisma/prisma/schema.prisma | PayoutRequest lines 1183–1210, PayoutStatus enum 772–781, PayoutMethod enum 783–786 |
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.
Get Platform Stats
Public aggregate counters — active creators, total messages, total earned. CDN- and Redis-cached for 1 hour. Render in trust badges, homepage hero, footer counters.