BIO.RE
Payment

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

FieldTypeRequiredValidationNotes
amountstringโœ“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

HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login
Idempotency-Key: <uuid>recommendedStable 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_..."
  }
}
FieldTypeNotes
sessionIdstringStripe Checkout session id. Useful for support trails; not required by the client.
checkoutUrlstringRedirect 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

HTTPcode / i18nKeyPayloadReason
400payment.wallet.error.min_load{ minLen โ†’ minLoad }Amount below wallet.min_load
400payment.wallet.error.max_load{ maxLoad }Amount above wallet.max_load
400payment.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.
400payment.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
503features.payment_disabledโ€”Admin kill switch PAYMENT is active

Side effects

  1. Idempotency cache hit โ€” if Idempotency-Key supplied AND Redis has a cached { sessionId, checkoutUrl } for that key: return it immediately. No Stripe call, no DB write.
  2. Validate amount โ€” read wallet.min_load / wallet.max_load / wallet.max_balance (admin-managed). Reject below min, above max, or if currentBalance + amount > maxBalance.
  3. Lazy wallet create โ€” getOrCreateWallet(userId) creates the FAN wallet row if missing.
  4. Resolve idempotency key: client-supplied OR auto-generated wallet_load_<userId>_<cents>_<minuteEpoch>. Forward to Stripe SDK.
  5. 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 }).
  6. Cache result if client supplied Idempotency-Key (Redis, capped 24h to match Stripe's window).
  7. Audit log + Prometheus counters (walletLoadTotal, walletLoadDurationMs).
  8. 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

POST
/api/v1/wallet/load
AuthorizationBearer <token>

In: header

Header Parameters

idempotency-key*string

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

SourcePathLines
Controllerapps/api-core/src/modules/payment/wallet.controller.ts38โ€“52 (loadWallet)
DTO (request)apps/api-core/src/modules/payment/dto/index.ts4โ€“7 (WalletLoadDto)
DTO (response)apps/api-core/src/modules/payment/dto/wallet-response.dto.ts45โ€“51 (WalletLoadSessionResponseDto)
Serviceapps/api-core/src/modules/payment/wallet.service.ts72โ€“147 (createLoadSession)
Webhook handlerapps/api-core/src/modules/payment/stripe-webhook.controller.ts(internal scope, not in this portal) โ€” credits the wallet on payment success
Stripe providerapps/api-core/src/modules/payment/stripe.provider.tsgetStripe()
Config keysapps/api-core/src/modules/config/config.service.tswallet.min_load, wallet.max_load, wallet.max_balance, wallet.idempotency_ttl_seconds (admin-managed)
Velocity guardapps/api-core/src/common/guards/velocity-check.guard.tsVelocityCheckGuard
Kill switchapps/api-core/src/common/guards/kill-switch.guard.tsRequireKillSwitch('PAYMENT') (class-level)
Prisma modelpackages/prisma/prisma/schema.prismaWallet, BalancePackage (FIFO consumption tracking, written by webhook)

On this page