Version: 2.0.0
Last Updated: January 25, 2026
For: Flutter Mobile Developers
Status: ✅ Stripe Integration Complete
Development: http://localhost:4000/api
Production: https://api.jobsphere.com/api
Authorization: Bearer <access_token>
Content-Type: application/json$100 Job Example:
├── Customer Pays: $105 (100 + 5%)
├── Platform Fee: $5 → Admin (on accept)
├── Service Fee: $20 → Admin (on complete)
└── Contractor Gets: $80 (on complete)
- Stripe Setup for Flutter
- Wallet Endpoints
- Stripe Connect Onboarding
- Offer Endpoints
- Job Endpoints
- Data Models
- Error Handling
- Testing Guide
Add to pubspec.yaml:
dependencies:
flutter_stripe: ^10.1.1 # Latest stable version
http: ^1.1.0
flutter_secure_storage: ^9.0.0// lib/core/stripe_config.dart
import 'package:flutter_stripe/flutter_stripe.dart';
class StripeConfig {
static const String publishableKey = 'pk_test_...'; // From backend
static Future<void> initialize() async {
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();
}
}
// Call in main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await StripeConfig.initialize();
runApp(MyApp());
}// The publishable key can be fetched from backend or hardcoded
// GET /api/wallet/stripe-config (optional endpoint)
// Returns: { "publishableKey": "pk_test_..." }Get user wallet balance.
Authentication: Required
Response:
{
"status": 200,
"message": "Wallet retrieved successfully",
"data": {
"_id": "wallet_id",
"user": "user_id",
"balance": 1000,
"escrowBalance": 105,
"pendingDeposits": 0,
"currency": "USD",
"totalEarnings": 5000,
"totalSpent": 2000,
"isActive": true,
"isFrozen": false
}
}Flutter Implementation:
Future<Wallet> getWallet() async {
final response = await http.get(
Uri.parse('$baseUrl/wallet'),
headers: await _getHeaders(),
);
if (response.statusCode == 200) {
return Wallet.fromJson(json.decode(response.body)['data']);
}
throw WalletException('Failed to load wallet');
}Add money to wallet using Stripe Payment Intent.
Authentication: Required
Role: Any registered user
Request Body:
{
"amount": 100,
"paymentMethodId": "pm_xxx..."
}Validation:
amount: Minimum $10, positive numberpaymentMethodId: Stripe payment method ID (obtained from Stripe SDK)
The paymentMethodId is obtained from the Stripe SDK on the client side. Here's how to get it:
Flutter (using flutter_stripe package):
-
Install the package:
# pubspec.yaml dependencies: flutter_stripe: ^10.0.0
-
Initialize Stripe (in your app startup):
import 'package:flutter_stripe/flutter_stripe.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Set your publishable key Stripe.publishableKey = 'pk_test_...'; // Your Stripe publishable key runApp(MyApp()); }
-
Create Payment Method (when user enters card details):
import 'package:flutter_stripe/flutter_stripe.dart'; Future<String?> createPaymentMethod() async { try { // Show card input form await Stripe.instance.presentPaymentSheet(); // Or create payment method from card details final paymentMethod = await Stripe.instance.createPaymentMethod( params: PaymentMethodParams.card( paymentMethodData: PaymentMethodData( billingDetails: BillingDetails( email: '[email protected]', name: 'John Doe', ), ), ), ); // Return the payment method ID return paymentMethod.id; // This is your paymentMethodId (pm_xxx...) } catch (e) { print('Error creating payment method: $e'); return null; } }
-
Use CardField widget (for custom UI):
import 'package:flutter_stripe/flutter_stripe.dart'; class PaymentScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ // Card input field CardField( onCardChanged: (card) { print('Card changed: ${card?.complete}'); }, ), ElevatedButton( onPressed: () async { // Create payment method final paymentMethod = await Stripe.instance.createPaymentMethod( params: PaymentMethodParams.card( paymentMethodData: PaymentMethodData(), ), ); // Send to your backend final paymentMethodId = paymentMethod.id; await depositToWallet(amount: 100, paymentMethodId: paymentMethodId); }, child: Text('Deposit'), ), ], ), ); } }
React/Web (using @stripe/stripe-js and @stripe/react-stripe-js):
-
Install packages:
npm install @stripe/stripe-js @stripe/react-stripe-js
-
Setup Stripe Elements:
import { loadStripe } from '@stripe/stripe-js'; import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; // Initialize Stripe const stripePromise = loadStripe('pk_test_...'); // Your publishable key function App() { return ( <Elements stripe={stripePromise}> <DepositForm /> </Elements> ); }
-
Create Payment Method:
function DepositForm() { const stripe = useStripe(); const elements = useElements(); const handleDeposit = async (amount) => { if (!stripe || !elements) return; // Get card element const cardElement = elements.getElement(CardElement); // Create payment method const { error, paymentMethod } = await stripe.createPaymentMethod({ type: 'card', card: cardElement, billing_details: { name: 'John Doe', email: '[email protected]', }, }); if (error) { console.error('Error:', error); return; } // Send to your backend const paymentMethodId = paymentMethod.id; // pm_xxx... await depositToWallet(amount, paymentMethodId); }; return ( <form onSubmit={(e) => { e.preventDefault(); handleDeposit(100); }}> <CardElement /> <button type="submit">Deposit $100</button> </form> ); }
Important Notes:
- Never send raw card details to your backend
- Always use Stripe SDK to tokenize card information
- The
paymentMethodId(pm_xxx...) is safe to send to your backend - Use your Stripe publishable key (pk_test_... or pk_live_...) on the client
- Keep your Stripe secret key (sk_test_... or sk_live_...) on the backend only
Response (Success):
{
"status": 200,
"message": "Payment initiated successfully",
"data": {
"paymentIntent": {
"id": "pi_xxx...",
"status": "succeeded",
"amount": 100,
"clientSecret": "pi_xxx_secret_xxx"
},
"transaction": {
"_id": "txn_123",
"amount": 100,
"type": "deposit",
"status": "pending",
"stripePaymentIntentId": "pi_xxx..."
},
"wallet": {
"balance": 900,
"pendingDeposits": 100
}
}
}Response (Requires 3D Secure):
{
"status": 200,
"message": "Additional authentication required",
"data": {
"requiresAction": true,
"clientSecret": "pi_xxx_secret_xxx",
"paymentIntentId": "pi_xxx..."
}
}Flutter Implementation (Complete Flow):
import 'package:flutter_stripe/flutter_stripe.dart';
class DepositService {
/// Complete deposit flow with Stripe
Future<DepositResult> makeDeposit(double amount) async {
try {
// Step 1: Create payment method from card
final paymentMethod = await Stripe.instance.createPaymentMethod(
params: PaymentMethodParams.card(
paymentMethodData: PaymentMethodData(
billingDetails: BillingDetails(
email: userEmail,
name: userName,
),
),
),
);
// Step 2: Call backend to create payment intent
final response = await http.post(
Uri.parse('$baseUrl/wallet/deposit'),
headers: await _getHeaders(),
body: json.encode({
'amount': amount,
'paymentMethodId': paymentMethod.id,
}),
);
final data = json.decode(response.body);
if (response.statusCode != 200) {
throw DepositException(data['message']);
}
// Step 3: Handle 3D Secure if required
if (data['data']['requiresAction'] == true) {
final clientSecret = data['data']['clientSecret'];
// This will show the 3DS authentication sheet
await Stripe.instance.handleNextAction(clientSecret);
// After 3DS, webhook will update the balance
return DepositResult(
status: 'processing',
message: 'Payment is being processed',
);
}
// Step 4: Payment succeeded immediately
return DepositResult(
status: 'succeeded',
amount: amount,
newBalance: data['data']['wallet']['balance'],
);
} on StripeException catch (e) {
throw DepositException(e.error.localizedMessage ?? 'Payment failed');
}
}
}Errors:
| Error | Message |
|---|---|
| 400 | "Minimum deposit amount is $10" |
| 400 | "Your card was declined" |
| 400 | "Your card has insufficient funds" |
| 400 | "Your card has expired" |
| 429 | "Rate limit exceeded. Try again later" (5 requests/hour) |
Withdraw money from wallet (contractors only).
Authentication: Required
Role: Contractor (with verified Stripe Connect account)
Prerequisites:
- User must have completed Stripe Connect onboarding
- Account status must be "verified"
charges_enabledandpayouts_enabledmust be true
Request Body:
{
"amount": 50
}Validation:
amount: Minimum $10, maximum $10,000- Must have sufficient balance
- Wallet must not be frozen
- Stripe Connect account must be verified
Response:
{
"status": 200,
"message": "Withdrawal initiated successfully",
"data": {
"transfer": {
"id": "tr_xxx...",
"amount": 50,
"status": "pending"
},
"transaction": {
"_id": "txn_123",
"amount": 50,
"type": "withdrawal",
"status": "pending",
"stripeTransferId": "tr_xxx..."
},
"wallet": {
"balance": 30,
"totalWithdrawals": 50
},
"estimatedArrival": "1-2 business days"
}
}Flutter Implementation:
Future<WithdrawalResult> requestWithdrawal(double amount) async {
final response = await http.post(
Uri.parse('$baseUrl/wallet/withdraw'),
headers: await _getHeaders(),
body: json.encode({'amount': amount}),
);
final data = json.decode(response.body);
if (response.statusCode == 200) {
return WithdrawalResult.fromJson(data['data']);
}
throw WithdrawalException(data['message']);
}Errors:
| Error | Message |
|---|---|
| 400 | "Only contractors can withdraw funds" |
| 400 | "Insufficient balance. Available: $X" |
| 400 | "Wallet is frozen. Please contact support" |
| 400 | "Please complete Stripe Connect onboarding" |
| 400 | "Stripe Connect account is pending verification" |
| 400 | "Your Stripe account is not fully activated" |
| 429 | "Rate limit exceeded. Try again later" (3 requests/hour) |
Get transaction history with pagination.
Authentication: Required
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | Page number |
| limit | number | 10 | Items per page (max 50) |
| type | string | - | Filter by transaction type |
Transaction Types:
deposit,withdrawal,escrow_hold,escrow_releaseplatform_fee,service_fee,contractor_payout,refund
Response:
{
"status": 200,
"message": "Transactions retrieved successfully",
"data": {
"transactions": [
{
"_id": "txn_123",
"type": "deposit",
"amount": 100,
"from": { "_id": "user_id", "full_name": "John Doe" },
"to": { "_id": "user_id", "full_name": "John Doe" },
"status": "completed",
"stripePaymentIntentId": "pi_xxx...",
"description": "Wallet deposit of 100",
"createdAt": "2026-01-24T10:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 50,
"totalPages": 5
}
}
}Check withdrawal status.
Authentication: Required
Role: Contractor (owner of the transaction)
Response:
{
"status": 200,
"message": "Withdrawal status retrieved",
"data": {
"transaction": {
"_id": "txn_123",
"amount": 50,
"status": "completed",
"stripeTransferId": "tr_xxx..."
},
"stripeTransfer": {
"id": "tr_xxx...",
"amount": 5000,
"currency": "usd",
"created": 1706185200,
"reversed": false
}
}
}Contractors must complete Stripe Connect onboarding to receive payouts.
Create Stripe Connect Express account.
Authentication: Required
Role: Contractor
Response:
{
"status": 201,
"message": "Stripe Connect account created. Please complete onboarding.",
"data": {
"accountId": "acct_xxx...",
"onboardingUrl": "https://connect.stripe.com/express/onboarding/...",
"expiresAt": "2026-01-25T11:00:00Z"
}
}Flutter Implementation:
import 'package:url_launcher/url_launcher.dart';
Future<void> startStripeOnboarding() async {
final response = await http.post(
Uri.parse('$baseUrl/wallet/connect-account'),
headers: await _getHeaders(),
);
if (response.statusCode == 201) {
final data = json.decode(response.body)['data'];
final onboardingUrl = data['onboardingUrl'];
// Open Stripe onboarding in browser
if (await canLaunchUrl(Uri.parse(onboardingUrl))) {
await launchUrl(
Uri.parse(onboardingUrl),
mode: LaunchMode.externalApplication,
);
}
}
}Errors:
| Error | Message |
|---|---|
| 400 | "Only contractors can create Connect accounts" |
| 400 | "Stripe Connect account already exists" |
| 429 | "Rate limit exceeded. Try again later" (2 requests/hour) |
Get Stripe Connect account status.
Authentication: Required
Role: Contractor
Response (No Account):
{
"status": 200,
"message": "No Stripe Connect account found",
"data": {
"hasAccount": false
}
}Response (Account Exists):
{
"status": 200,
"message": "Stripe Connect account status retrieved",
"data": {
"hasAccount": true,
"accountId": "acct_xxx...",
"status": "verified",
"onboardingComplete": true,
"chargesEnabled": true,
"payoutsEnabled": true,
"requirements": {
"currentlyDue": [],
"pendingVerification": [],
"errors": []
},
"capabilities": {
"card_payments": "active",
"transfers": "active"
}
}
}Account Status Values:
| Status | Description | Can Withdraw? |
|---|---|---|
| pending | Onboarding incomplete | ❌ No |
| verified | Fully verified | ✅ Yes |
| rejected | Verification failed | ❌ No |
Flutter Implementation:
Future<ConnectAccountStatus> getConnectAccountStatus() async {
final response = await http.get(
Uri.parse('$baseUrl/wallet/connect-account/status'),
headers: await _getHeaders(),
);
final data = json.decode(response.body)['data'];
return ConnectAccountStatus.fromJson(data);
}
// UI Logic
Widget buildPayoutSection() {
return FutureBuilder<ConnectAccountStatus>(
future: getConnectAccountStatus(),
builder: (context, snapshot) {
if (snapshot.data?.onboardingComplete != true) {
return ElevatedButton(
onPressed: startStripeOnboarding,
child: Text('Complete Payout Setup'),
);
}
return ElevatedButton(
onPressed: () => showWithdrawalDialog(),
child: Text('Withdraw Funds'),
);
},
);
}Get a new onboarding link if the previous one expired.
Authentication: Required
Role: Contractor with existing Connect account
Response:
{
"status": 200,
"message": "New onboarding link generated",
"data": {
"onboardingUrl": "https://connect.stripe.com/express/onboarding/...",
"expiresAt": "2026-01-25T11:00:00Z"
}
}Customer sends offer to contractor.
Authentication: Required
Role: Customer
Path Parameters:
applicationId: Job application ID
Request Body:
{
"amount": 100,
"timeline": "7 days",
"description": "Complete plumbing repair as discussed"
}Validation:
amount: Min $10, Max $10,000timeline: Min 1 char, Max 100 charsdescription: Min 10 chars, Max 1000 chars
Response:
{
"status": 201,
"message": "Offer sent successfully",
"data": {
"offer": {
"_id": "offer_123",
"job": "job_123",
"customer": "customer_123",
"contractor": "contractor_123",
"amount": 100,
"platformFee": 5,
"serviceFee": 20,
"contractorPayout": 80,
"totalCharge": 105,
"timeline": "7 days",
"status": "pending",
"expiresAt": "2026-01-31T10:00:00Z"
},
"walletBalance": 895,
"amounts": {
"jobBudget": 100,
"platformFee": 5,
"serviceFee": 20,
"contractorPayout": 80,
"totalCharge": 105,
"adminTotal": 25
}
}
}What Happens:
- $105 (job + 5% platform fee) held in escrow
- Offer created with status "pending"
- Contractor notified via push notification
- Offer expires in 7 days if not responded
Errors:
400: "Insufficient balance. Required: $X, Available: $Y"400: "Job is not open for offers"400: "An offer already exists for this job"403: "Not authorized"
Contractor accepts offer.
Authentication: Required
Role: Contractor
Response:
{
"status": 200,
"message": "Offer accepted successfully",
"data": {
"offer": {
"_id": "offer_123",
"status": "accepted",
"acceptedAt": "2026-01-24T11:00:00Z"
},
"job": {
"_id": "job_123",
"status": "assigned",
"contractorId": "contractor_123"
},
"payment": {
"platformFee": 5,
"serviceFee": 20,
"contractorPayout": 80
}
}
}What Happens:
- Offer status → "accepted"
- Job status → "assigned"
- Platform fee ($5) → Admin wallet
- Remaining $100 stays in escrow
- Other applications rejected
Contractor rejects offer.
Authentication: Required
Role: Contractor
Request Body:
{
"reason": "Timeline too short for quality work"
}Response:
{
"status": 200,
"message": "Offer rejected successfully",
"data": {
"offer": {
"_id": "offer_123",
"status": "rejected",
"rejectedAt": "2026-01-24T11:00:00Z",
"rejectionReason": "Timeline too short"
},
"refundAmount": 105
}
}What Happens:
- Offer status → "rejected"
- Full refund ($105) → Customer wallet
- Customer can send new offer
Mark job as complete (customer only).
Authentication: Required
Role: Customer
Prerequisites:
- Job must be "in_progress"
- Customer must own the job
- Offer must be "accepted"
Response:
{
"status": 200,
"message": "Job marked as complete",
"data": {
"job": {
"_id": "job_123",
"status": "completed",
"completedAt": "2026-01-24T15:00:00Z"
},
"payment": {
"serviceFee": 20,
"contractorPayout": 80,
"adminCommission": 25
}
}
}What Happens:
- Job status → "completed"
- Service fee ($20) → Admin wallet
- Contractor payout ($80) → Contractor wallet
- Escrow released
Cancel job.
Authentication: Required
Role: Customer or Admin
Request Body:
{
"reason": "No longer needed"
}Response:
{
"status": 200,
"message": "Job cancelled successfully",
"data": {
"job": {
"_id": "job_123",
"status": "cancelled"
},
"refundAmount": 105
}
}class Wallet {
final String id;
final String user;
final double balance; // Available funds
final double escrowBalance; // Funds in escrow
final double pendingDeposits; // Stripe deposits processing
final String currency; // "USD"
final bool isActive;
final bool isFrozen;
final double totalEarnings; // Lifetime earnings
final double totalSpent; // Lifetime spending
final double totalWithdrawals; // Lifetime withdrawals
final DateTime createdAt;
final DateTime updatedAt;
}class Transaction {
final String id;
final String type; // deposit, withdrawal, etc.
final double amount;
final User from;
final User to;
final String? offerId;
final String? jobId;
final String status; // pending, completed, failed
final String? stripePaymentIntentId;
final String? stripeTransferId;
final String description;
final String? failureReason;
final DateTime createdAt;
}class ConnectAccountStatus {
final bool hasAccount;
final String? accountId;
final String status; // pending, verified, rejected
final bool onboardingComplete;
final bool chargesEnabled;
final bool payoutsEnabled;
final List<String> currentlyDue;
final List<String> pendingVerification;
}{
"status": 400,
"message": "Error description",
"data": null,
"errors": [
{
"field": "amount",
"message": "Amount must be positive"
}
]
}| Error | Code | User Message |
|---|---|---|
| Card declined | card_declined |
"Your card was declined" |
| Insufficient funds | insufficient_funds |
"Your card has insufficient funds" |
| Expired card | expired_card |
"Your card has expired" |
| Incorrect CVC | incorrect_cvc |
"Your card's security code is incorrect" |
| Processing error | processing_error |
"An error occurred. Please try again" |
| Rate limited | rate_limit |
"Too many attempts. Try again later" |
class ApiException implements Exception {
final String message;
final int statusCode;
final String? field;
ApiException(this.message, this.statusCode, [this.field]);
}
Future<T> handleApiResponse<T>(
http.Response response,
T Function(Map<String, dynamic>) parser,
) {
final data = json.decode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return parser(data['data']);
}
throw ApiException(
data['message'] ?? 'An error occurred',
response.statusCode,
);
}| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 |
✅ Success |
4000 0000 0000 0002 |
❌ Declined (insufficient funds) |
4000 0000 0000 0341 |
❌ Declined (generic) |
4000 0000 0000 9995 |
❌ Declined (insufficient funds) |
4000 0000 0000 0069 |
❌ Expired card |
4000 0000 0000 0127 |
❌ Incorrect CVC |
4000 0027 6000 3184 |
🔐 Requires 3D Secure |
- Expiry: Any future date (e.g.,
12/30) - CVC: Any 3 digits (e.g.,
123) - ZIP: Any 5 digits (e.g.,
12345)
- Development: Use
pk_test_...andsk_test_...keys - Use test cards listed above
- Backend webhooks process automatically via Stripe CLI:
stripe listen --forward-to localhost:4000/api/webhooks/stripe
// Customer login
final loginResponse = await http.post(
Uri.parse('$baseUrl/auth/login'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'email': '[email protected]',
'password': 'password123',
}),
);| Endpoint | Limit | Window |
|---|---|---|
| POST /wallet/deposit | 5 requests | 1 hour |
| POST /wallet/withdraw | 3 requests | 1 hour |
| POST /wallet/connect-account | 2 requests | 1 hour |
Response when rate limited:
{
"status": 429,
"message": "Too many requests. Please try again in X minutes."
}import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();
Future<void> saveToken(String token) async {
await storage.write(key: 'access_token', value: token);
}
Future<String?> getToken() async {
return await storage.read(key: 'access_token');
}RefreshIndicator(
onRefresh: () => getWallet(),
child: WalletView(),
)if (wallet.pendingDeposits > 0) {
return Text('+ \$${wallet.pendingDeposits} pending');
}// Always check for requires_action in deposit response
if (response.data['requiresAction'] == true) {
await Stripe.instance.handleNextAction(clientSecret);
}- Stripe Flutter SDK: https://pub.dev/packages/flutter_stripe
- Stripe API Docs: https://stripe.com/docs/api
- Stripe Test Cards: https://stripe.com/docs/testing#cards
- Backend API Swagger: http://localhost:4000/api-docs
Related Documentation: