Initiate Stripe Connect
Create a Stripe Connect Express account and return a hosted onboarding URL. KYC must be APPROVED first. Idempotent — repeated calls return a fresh link for the same account.
POST /api/v1/creators/stripe-connect/initiate — 🔑 Bearer · Rate limit: 10 req / hour
Creates a Stripe Connect Express account for the calling creator and returns a fresh onboarding link. KYC must be APPROVED before this endpoint will work — the platform requires identity verification before opening up payouts. Idempotent: if the creator already has a Stripe account, a new onboarding link is generated against it (no duplicate account creation).
KYC gate is hard. A creator with kycStatus !== 'APPROVED' can't initiate Stripe Connect — call POST /creators/kyc/start first and wait for the webhook to flip status to APPROVED. Surface "Complete identity verification first" UI when you see creator.stripe.kyc_required.
Race-safe via atomic claim. Concurrent calls don't create duplicate Stripe accounts — the first call wins via updateMany({ where: stripeAccountId: null }); subsequent racers either find the freshly-set id and return a fresh link, or fail with connect_in_progress if they slip between the claim and the persist.
Request
No body, no params. Identity comes from the bearer token.
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
201 Created — ApiResponseOf<StripeConnectInitiateResponseDto>
{
"success": true,
"data": {
"accountId": "acct_...",
"onboardingUrl": "https://connect.stripe.com/express/onboarding/..."
}
}| Field | Type | Notes |
|---|---|---|
accountId | string | Stripe Express account id (saved on CreatorProfile.stripeAccountId). Treat as opaque — pass back to /refresh-link and /dashboard-link only via the platform endpoints. |
onboardingUrl | string | Fresh hosted onboarding URL. Open in a new tab or full-page redirect. Single-use, time-limited — fetch a new one via /refresh-link if it expires. |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | creator.stripe.kyc_required | kycStatus !== 'APPROVED' — complete KYC first |
400 | creator.stripe.connect_in_progress | Concurrent call won the atomic claim before this one persisted |
400 | creator.stripe.service_unavailable | Stripe SDK is not configured (server-side env / config issue) — surface "payment temporarily unavailable" |
401 | (guard) | Missing / invalid bearer token |
404 | creator.stripe.not_creator | No CreatorProfile for this user — call POST /creators/upgrade first |
429 | (throttle) | Rate limit exceeded (10 req/hour) |
Side effects
- Lookup
CreatorProfile(withuser.email); thrownot_creatorif missing. - KYC gate: assert
kycStatus === APPROVED; otherwisekyc_required. - Idempotent path — if
stripeAccountIdalready set: skip account creation, just callcreateAccountLink(stripeAccountId)and return. - Atomic claim —
updateMany({ where: { userId, stripeAccountId: null }, data: { stripeAccountStatus: PENDING } }). Zero rows touched → another concurrent request beat us; re-read and either return its result or throwconnect_in_progress. - Stripe SDK creates the Express account —
accounts.create({ type: 'express', country: <stripe.connect_country admin-managed, default 'US'>, email: user.email, metadata: { creatorId, userId, platform: 'biore' }, capabilities: { transfers: { requested: true } } }). - Persist
stripeAccountId(update({ stripeAccountId })). - Audit log:
[stripe-connect] Account created: {accountId} for creator {creatorId}. - Generate the onboarding URL via
createAccountLink(accountId)(StripeaccountLinks.create). - Return
{ accountId, onboardingUrl }. - Webhook updates status fields — Stripe's
account.updatedwebhook flipsstripeChargesEnabled/stripePayoutsEnabled/stripeAccountStatusserver-side as the creator completes onboarding (handled by an internal webhook controller, not this portal).
Code samples
curl -X POST https://api.bio.re/api/v1/creators/stripe-connect/initiate \
-H "Authorization: Bearer $ACCESS_TOKEN"type StripeConnectInitiate = {
accountId: string;
onboardingUrl: string;
};
async function initiateStripeConnect(accessToken: string): Promise<StripeConnectInitiate> {
const res = await fetch('https://api.bio.re/api/v1/creators/stripe-connect/initiate', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Stripe Connect initiate failed'), {
code: json?.error?.code,
});
}
return json.data;
}
// Usage: redirect to onboardingUrl in a new tab
function openOnboarding(onboardingUrl: string) {
window.location.assign(onboardingUrl);
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useInitiateStripeConnect() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch('/api/v1/creators/stripe-connect/initiate', { method: 'POST' });
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Stripe Connect initiate failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as StripeConnectInitiate;
},
onSuccess: () => {
// Status flipped to PENDING server-side
qc.invalidateQueries({ queryKey: ['creators', 'stripe-connect', 'status'] });
qc.invalidateQueries({ queryKey: ['creators', 'payout-settings'] });
qc.invalidateQueries({ queryKey: ['creators', 'profile'] });
},
});
}Try it
Authorization
bearer In: header
Response Body
application/json
application/json
curl -X POST "https://loading/api/v1/creators/stripe-connect/initiate"{
"success": true,
"data": {
"accountId": "acct_1234567890",
"onboardingUrl": "string"
}
}{
"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/creator/stripe-connect.controller.ts | 19–25 (initiate) |
| DTO (response) | apps/api-core/src/modules/creator/dto/stripe-connect-response.dto.ts | 9–15 (StripeConnectInitiateResponseDto) |
| Service | apps/api-core/src/modules/creator/stripe-connect.service.ts | 25–98 (initiateConnect) |
| Stripe provider | apps/api-core/src/modules/payment/stripe.provider.ts | getStripe(), requireStripe() |
| Config | apps/api-core/src/modules/config/config.service.ts | stripe.connect_country (admin-managed, default 'US') |
| Webhook (status sync) | apps/api-core/src/modules/payment/ | Stripe account.updated handler (internal scope) |
| Prisma model | packages/prisma/prisma/schema.prisma | CreatorProfile.stripeAccountId, CreatorProfile.stripeAccountStatus (enum StripeAccountStatus), CreatorProfile.kycStatus (enum KycStatus.APPROVED) |
Get KYC Status
Read current KYC state plus the active provider id and completion timestamp. Owner-only. Provider value is opaque to clients.
Get Stripe Connect Status
Real-time pull from the Stripe API with DB sync. Falls back to stored DB values when Stripe SDK is unavailable. Returns charges/payouts enabled flags + onboarding completion flag.