Create Wallet Load Session
Create a Stripe Checkout session for a wallet top-up. Validates min/max/balance-cap via admin config. Idempotency-Key header recommended for retry safety. Actual credit happens server-side via webhook.
POST /api/v1/wallet/load โ ๐ Bearer + VelocityCheckGuard ยท Kill-switched
Creates a Stripe Checkout session for a wallet top-up and returns the sessionId plus a checkoutUrl to redirect the user to. Validates the amount against admin-managed wallet.min_load / wallet.max_load / wallet.max_balance (per-account ceiling). Idempotency-Key header is recommended โ both the server cache and Stripe's idempotency layer use it to prevent double-charges on retry.
The balance is NOT credited by this endpoint. This endpoint only creates the Stripe Checkout session. After the user completes the payment on Stripe's hosted page, Stripe sends a webhook to the platform; that webhook handler is what actually increments the wallet balance (in a row-locked transaction with double-entry ledger). Your client must poll /wallet/balance (or refetch on success_url redirect) to see the new balance โ there's no synchronous "balance after load" return value.
Idempotency-Key header is two-layered. When supplied, the server caches the resulting { sessionId, checkoutUrl } in Redis (TTL = wallet.idempotency_ttl_seconds, capped 24h) AND passes it to Stripe. Subsequent calls with the same key return the cached pair without creating a new session. Always send a stable, request-scoped key (e.g. UUID per submit) to avoid creating multiple sessions on a network retry.
Velocity-checked. VelocityCheckGuard rate-limits per-user beyond the global throttle. Hitting it returns 429 โ back off and retry rather than spamming.
Request
Body โ WalletLoadDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
amount | string | โ | regex ^\d+(\.\d{1,2})?$ | Decimal as string (e.g. "25.00"). Server validates against wallet.min_load / wallet.max_load AND the account's remaining headroom (wallet.max_balance - currentBalance). |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | โ | JWT from POST /auth/login |
Idempotency-Key: <uuid> | recommended | Stable retry key. Cached server-side (Redis, TTL โค 24h) AND forwarded to Stripe. Without it, the server falls back to a per-minute auto-key (wallet_load_<userId>_<cents>_<minute>) โ safe enough but not as tight as a client-supplied UUID. |
Response
200 OK โ ApiResponseOf<WalletLoadSessionResponseDto>
{
"success": true,
"data": {
"sessionId": "cs_test_...",
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_..."
}
}| Field | Type | Notes |
|---|---|---|
sessionId | string | Stripe Checkout session id. Useful for support trails; not required by the client. |
checkoutUrl | string | Redirect URL. Open in a new tab or full-page redirect. The user completes payment on Stripe's hosted flow and is redirected back to success_url / cancel_url (set server-side via the CLIENT_URL env). |
Errors
| HTTP | code / i18nKey | Payload | Reason |
|---|---|---|---|
400 | payment.wallet.error.min_load | { minLen โ minLoad } | Amount below wallet.min_load |
400 | payment.wallet.error.max_load | { maxLoad } | Amount above wallet.max_load |
400 | payment.wallet.error.max_balance | { maxCanLoad } | Loading this amount would push balance past wallet.max_balance. Payload tells the client how much they could still load. |
400 | payment.wallet.error.service_not_configured | โ | Stripe SDK missing โ server-side env / config issue. Surface "payment temporarily unavailable". |
400 | (DTO validation) | โ | Amount string doesn't match the regex |
401 | (guard) | โ | Missing / invalid bearer token |
429 | (velocity / throttle) | โ | Per-user velocity limit exhausted |
503 | features.payment_disabled | โ | Admin kill switch PAYMENT is active |
Side effects
- Idempotency cache hit โ if
Idempotency-Keysupplied AND Redis has a cached{ sessionId, checkoutUrl }for that key: return it immediately. No Stripe call, no DB write. - Validate amount โ read
wallet.min_load/wallet.max_load/wallet.max_balance(admin-managed). Reject below min, above max, or ifcurrentBalance + amount > maxBalance. - Lazy wallet create โ
getOrCreateWallet(userId)creates the FAN wallet row if missing. - Resolve idempotency key: client-supplied OR auto-generated
wallet_load_<userId>_<cents>_<minuteEpoch>. Forward to Stripe SDK. - Create Stripe Checkout session โ
stripe.checkout.sessions.create({ mode: 'payment', payment_method_types: ['card'], line_items: [{ price_data: { currency, product_data: { name: 'BIO.RE Wallet Load โ <symbol><amount>' }, unit_amount: <cents> }, quantity: 1 }], metadata: { userId, walletLoad: 'true', amount }, client_reference_id: userId, success_url, cancel_url }). - Cache result if client supplied
Idempotency-Key(Redis, capped 24h to match Stripe's window). - Audit log + Prometheus counters (
walletLoadTotal,walletLoadDurationMs). - Return
{ sessionId, checkoutUrl }. The wallet balance is NOT updated yet โ that happens in the Stripe webhook handler after the user completes payment.
Code samples
curl -X POST https://api.bio.re/api/v1/wallet/load \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"amount": "25.00"}'type WalletLoadSession = {
sessionId: string;
checkoutUrl: string;
};
async function createWalletLoadSession(
accessToken: string,
amount: string,
idempotencyKey: string,
): Promise<WalletLoadSession> {
const res = await fetch('https://api.bio.re/api/v1/wallet/load', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Wallet load failed'), {
code: json?.error?.code,
maxCanLoad: json?.error?.maxCanLoad, // present on 'max_balance'
});
}
return json.data;
}
// Usage: redirect on success
async function startLoad(accessToken: string, amount: string) {
const session = await createWalletLoadSession(accessToken, amount, crypto.randomUUID());
window.location.assign(session.checkoutUrl);
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCreateWalletLoadSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: { amount: string; idempotencyKey: string }) => {
const res = await fetch('/api/v1/wallet/load', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': vars.idempotencyKey,
},
body: JSON.stringify({ amount: vars.amount }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Wallet load failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
maxCanLoad: json?.error?.maxCanLoad,
});
}
return json.data as WalletLoadSession;
},
// After redirect-back from Stripe, refetch balance โ webhook may have credited it
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['wallet', 'balance'] });
qc.invalidateQueries({ queryKey: ['wallet', 'activity'] });
},
});
}Try it
Authorization
bearer In: header
Header Parameters
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/wallet/load" \ -H "idempotency-key: string" \ -H "Content-Type: application/json" \ -d '{ "amount": "25.00" }'{
"success": true,
"data": {
"sessionId": "string",
"checkoutUrl": "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"
}
}{
"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/payment/wallet.controller.ts | 38โ52 (loadWallet) |
| DTO (request) | apps/api-core/src/modules/payment/dto/index.ts | 4โ7 (WalletLoadDto) |
| DTO (response) | apps/api-core/src/modules/payment/dto/wallet-response.dto.ts | 45โ51 (WalletLoadSessionResponseDto) |
| Service | apps/api-core/src/modules/payment/wallet.service.ts | 72โ147 (createLoadSession) |
| Webhook handler | apps/api-core/src/modules/payment/stripe-webhook.controller.ts | (internal scope, not in this portal) โ credits the wallet on payment success |
| Stripe provider | apps/api-core/src/modules/payment/stripe.provider.ts | getStripe() |
| Config keys | apps/api-core/src/modules/config/config.service.ts | wallet.min_load, wallet.max_load, wallet.max_balance, wallet.idempotency_ttl_seconds (admin-managed) |
| Velocity guard | apps/api-core/src/common/guards/velocity-check.guard.ts | VelocityCheckGuard |
| Kill switch | apps/api-core/src/common/guards/kill-switch.guard.ts | RequireKillSwitch('PAYMENT') (class-level) |
| Prisma model | packages/prisma/prisma/schema.prisma | Wallet, BalancePackage (FIFO consumption tracking, written by webhook) |
Get Wallet Balance
Read the calling user's FAN wallet balance and frozen flag. Returns 0.00 + frozen=false if no wallet row exists yet (no auto-create on read).
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.