Version: 2.0.0
Last Updated: January 29, 2026
Target Platform: Flutter Mobile App
Status: ✅ Production Ready
📱 Integration Note: All API endpoints, request/response formats, and error codes in this guide are accurate and match the production backend. Code examples are provided in Dart/Flutter for mobile app integration.
- Overview
- Authentication
- Wallet API
- Offer API
- Job API
- Transaction History API
- Stripe Integration
- Error Handling
- Best Practices
This document provides complete API reference for integrating the JobSphere payment system into your Flutter mobile application. It includes endpoints, request/response formats, and Flutter-specific implementation guidance.
Production: https://api.jobsphere.com
Development: http://localhost:3000
All endpoints except webhooks require JWT authentication:
headers: {
'Authorization': 'Bearer ${accessToken}',
'Content-Type': 'application/json',
}All API responses follow this structure:
Success Response:
{
"status": 200,
"message": "Success message",
"data": { /* response data */ }
}Error Response:
{
"status": 400,
"message": "Error message",
"data": null,
"errors": [
{
"field": "fieldName",
"message": "Specific error"
}
]
}Endpoint: POST /api/auth/register
Request:
{
"email": "[email protected]",
"password": "SecurePass123",
"name": "John Doe",
"role": "customer" // or "contractor"
}Response:
{
"status": 201,
"message": "Registration successful",
"data": {
"user": {
"_id": "507f1f77bcf86cd799439011",
"email": "[email protected]",
"name": "John Doe",
"role": "customer"
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
}
}Endpoint: POST /api/auth/login
Request:
{
"email": "[email protected]",
"password": "SecurePass123"
}Response: Same as register
Endpoint: GET /api/wallet
Headers: Requires authentication
Response:
{
"status": 200,
"message": "Wallet retrieved successfully",
"data": {
"_id": "507f1f77bcf86cd799439012",
"user": "507f1f77bcf86cd799439011",
"balance": 150.00,
"currency": "USD",
"isActive": true,
"isFrozen": false,
"totalEarnings": 500.00,
"totalSpent": 350.00,
"totalWithdrawals": 100.00,
"createdAt": "2026-01-15T10:00:00.000Z",
"updatedAt": "2026-01-28T12:30:00.000Z"
}
}Flutter Implementation:
import 'package:http/http.dart' as http;
import 'dart:convert';
class WalletService {
final String baseUrl;
final String accessToken;
WalletService({required this.baseUrl, required this.accessToken});
Future<Map<String, dynamic>> getWallet() async {
final response = await http.get(
Uri.parse('$baseUrl/api/wallet'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
throw Exception('Failed to load wallet');
}
}
}Endpoint: POST /api/wallet/deposit
Headers: Requires authentication
Request:
{
"amount": 100.00
}Response:
{
"status": 200,
"message": "Deposit initiated successfully",
"data": {
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_...",
"sessionId": "cs_test_a1b2c3d4e5f6",
"amount": 100.00
}
}Flutter Implementation:
import 'package:url_launcher/url_launcher.dart';
class WalletService {
// ... previous code
Future<void> depositMoney(double amount) async {
// Step 1: Create deposit request
final response = await http.post(
Uri.parse('$baseUrl/api/wallet/deposit'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: json.encode({'amount': amount}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body)['data'];
final checkoutUrl = data['checkoutUrl'];
// Step 2: Open Stripe Checkout in browser
await _openStripeCheckout(checkoutUrl);
// Step 3: Listen for completion (via polling or push notification)
// See "Stripe Integration" section for details
} else {
throw Exception('Failed to create deposit');
}
}
Future<void> _openStripeCheckout(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
// Opens in default browser (not in-app)
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Could not launch checkout URL');
}
}
}Important Notes:
- The backend returns a Stripe Checkout URL
- Do NOT open Stripe UI in-app - use
LaunchMode.externalApplication - User completes payment in their default browser
- Payment completion is confirmed via webhook (backend handles this)
- Frontend should poll wallet balance or listen for push notifications
Endpoint: POST /api/wallet/withdraw
Headers: Requires authentication (contractor role)
Request:
{
"amount": 50.00
}Response:
{
"status": 200,
"message": "Withdrawal request submitted",
"data": {
"withdrawal": {
"_id": "507f1f77bcf86cd799439013",
"user": "507f1f77bcf86cd799439011",
"amount": 50.00,
"status": "pending",
"requestedAt": "2026-01-28T12:00:00.000Z"
},
"message": "Withdrawal request submitted. Admin will review and process."
}
}Flutter Implementation:
Future<Map<String, dynamic>> requestWithdrawal(double amount) async {
final response = await http.post(
Uri.parse('$baseUrl/api/wallet/withdraw'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: json.encode({'amount': amount}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
final error = json.decode(response.body);
throw Exception(error['message']);
}
}Validation:
- Minimum: $10
- Maximum: $10,000
- Role: Contractors only
- Requires sufficient balance
- Requires Stripe Connect account setup
Endpoint: POST /api/job-request/:applicationId/send-offer
Headers: Requires authentication (customer role)
Request:
{
"amount": 100.00,
"timeline": "2 weeks",
"description": "Complete the project as described in the job posting"
}Response:
{
"status": 201,
"message": "Offer sent successfully",
"data": {
"offer": {
"_id": "507f1f77bcf86cd799439014",
"job": "507f1f77bcf86cd799439015",
"customer": "507f1f77bcf86cd799439011",
"contractor": "507f1f77bcf86cd799439016",
"application": "507f1f77bcf86cd799439017",
"amount": 100.00,
"platformFee": 5.00,
"serviceFee": 20.00,
"contractorPayout": 80.00,
"totalCharge": 105.00,
"timeline": "2 weeks",
"description": "Complete the project as described in the job posting",
"status": "pending",
"expiresAt": "2026-02-04T12:00:00.000Z",
"createdAt": "2026-01-28T12:00:00.000Z"
},
"commissions": {
"platformFee": 5.00,
"serviceFee": 20.00,
"contractorPayout": 80.00,
"totalCharge": 105.00,
"adminCommission": 25.00
}
}
}Flutter Implementation:
Future<Map<String, dynamic>> sendOffer({
required String applicationId,
required double amount,
required String timeline,
required String description,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/api/job-request/$applicationId/send-offer'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: json.encode({
'amount': amount,
'timeline': timeline,
'description': description,
}),
);
if (response.statusCode == 201) {
final data = json.decode(response.body);
return data['data'];
} else {
final error = json.decode(response.body);
throw Exception(error['message']);
}
}Validation:
- Amount: $10 - $10,000
- Timeline: 1-100 characters
- Description: 10-1000 characters
- Requires sufficient wallet balance
- One offer per job only
Important: When offer is sent, wallet balance is NOT immediately deducted. Deduction happens only when contractor accepts.
Endpoint: POST /api/job-request/offer/:offerId/accept
Headers: Requires authentication (contractor role)
Response:
{
"status": 200,
"message": "Offer accepted successfully",
"data": {
"offer": {
"_id": "507f1f77bcf86cd799439014",
"status": "accepted",
"acceptedAt": "2026-01-28T12:30:00.000Z",
...
},
"job": {
"_id": "507f1f77bcf86cd799439015",
"status": "assigned",
"contractorId": "507f1f77bcf86cd799439016",
"offerId": "507f1f77bcf86cd799439014",
"assignedAt": "2026-01-28T12:30:00.000Z",
...
},
"payment": {
"customerCharged": 105.00,
"contractorWillReceive": 80.00,
"platformCommission": 25.00
}
}
}Flutter Implementation:
Future<Map<String, dynamic>> acceptOffer(String offerId) async {
final response = await http.post(
Uri.parse('$baseUrl/api/job-request/offer/$offerId/accept'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
final error = json.decode(response.body);
throw Exception(error['message']);
}
}What Happens:
- Customer wallet: -$105 (database only, no real money transfer)
- Admin wallet: +$105 (database only)
- Job status:
open→assigned - Contractor assigned to job
- Other applications automatically rejected
Endpoint: POST /api/job-request/offer/:offerId/reject
Headers: Requires authentication (contractor role)
Request (optional):
{
"reason": "Timeline is too short for this project"
}Response:
{
"status": 200,
"message": "Offer rejected successfully",
"data": {
"offer": {
"_id": "507f1f77bcf86cd799439014",
"status": "rejected",
"rejectedAt": "2026-01-28T12:30:00.000Z",
"rejectionReason": "Timeline is too short for this project",
...
},
"refundAmount": 105.00 // If offer was previously accepted
}
}Flutter Implementation:
Future<Map<String, dynamic>> rejectOffer(String offerId, {String? reason}) async {
final response = await http.post(
Uri.parse('$baseUrl/api/job-request/offer/$offerId/reject'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: json.encode({
if (reason != null) 'reason': reason,
}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
final error = json.decode(response.body);
throw Exception(error['message']);
}
}What Happens:
- If offer was accepted: Full refund to customer (wallet adjustment only)
- Application status reset to
pending - Job status reset to
open(if was assigned) - Customer can send new offer
Endpoint: GET /api/job/:id
Headers: Requires authentication
Response:
{
"status": 200,
"message": "Job retrieved successfully",
"data": {
"_id": "507f1f77bcf86cd799439015",
"title": "Build Mobile App",
"description": "Need a Flutter mobile app...",
"budget": 100.00,
"status": "assigned",
"postedBy": {...},
"contractorId": {...},
"offerId": "507f1f77bcf86cd799439014",
"category": "Mobile Development",
"location": "Remote",
"assignedAt": "2026-01-28T12:30:00.000Z",
"createdAt": "2026-01-28T10:00:00.000Z"
}
}Endpoint: PATCH /api/job/:id/status
Headers: Requires authentication
Request:
{
"status": "in_progress"
}Valid Status Transitions:
open→assigned,cancelledassigned→in_progress,cancelledin_progress→completed,cancelled
Response:
{
"status": 200,
"message": "Job status updated successfully",
"data": {
"_id": "507f1f77bcf86cd799439015",
"status": "in_progress",
...
}
}Endpoint: POST /api/job/:id/complete
Headers: Requires authentication (customer role)
Prerequisites: Job must be in in_progress status
Response:
{
"status": 200,
"message": "Job completion request submitted",
"data": {
"completionRequest": {
"_id": "507f1f77bcf86cd799439018",
"job": "507f1f77bcf86cd799439015",
"requestedBy": "507f1f77bcf86cd799439011",
"status": "pending",
"requestedAt": "2026-01-28T14:00:00.000Z"
},
"message": "Admin will review and approve completion"
}
}Flutter Implementation:
Future<Map<String, dynamic>> completeJob(String jobId) async {
final response = await http.post(
Uri.parse('$baseUrl/api/job/$jobId/complete'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
final error = json.decode(response.body);
throw Exception(error['message']);
}
}What Happens:
- Creates completion request for admin review
- Admin approves in admin dashboard
- On approval:
- Admin wallet: -$80 (keeps $25 commission)
- Contractor wallet: +$80
- Admin initiates Stripe Connect transfer to contractor
- Job status:
completed - Notifications sent to all parties
Endpoint: POST /api/job/:id/cancel
Headers: Requires authentication (customer or admin)
Request (optional):
{
"reason": "Project requirements changed"
}Response:
{
"status": 200,
"message": "Job cancelled successfully",
"data": {
"job": {
"_id": "507f1f77bcf86cd799439015",
"status": "cancelled",
"cancelledAt": "2026-01-28T14:00:00.000Z",
"cancellationReason": "Project requirements changed",
...
},
"refundAmount": 105.00 // If offer was accepted
}
}What Happens:
- If offer was accepted: Full refund to customer (wallet adjustment only)
- Job status:
cancelled - Notification to contractor
Endpoint: GET /api/wallet/transactions
Headers: Requires authentication
Query Parameters:
page(optional): Page number (default: 1)limit(optional): Items per page (default: 20, max: 100)type(optional): Filter by transaction type
Example:
GET /api/wallet/transactions?page=1&limit=20&type=deposit
Response:
{
"status": 200,
"message": "Transactions retrieved successfully",
"data": {
"transactions": [
{
"_id": "507f1f77bcf86cd799439019",
"type": "deposit",
"amount": 100.00,
"to": {
"_id": "507f1f77bcf86cd799439011",
"name": "John Doe",
"email": "[email protected]"
},
"status": "completed",
"description": "Deposit $100 via Stripe",
"completedAt": "2026-01-28T10:30:00.000Z",
"createdAt": "2026-01-28T10:29:00.000Z"
},
{
"_id": "507f1f77bcf86cd79943901a",
"type": "wallet_transfer",
"amount": 105.00,
"from": {
"_id": "507f1f77bcf86cd799439011",
"name": "John Doe"
},
"to": {
"_id": "507f1f77bcf86cd799439020",
"name": "Admin"
},
"offer": "507f1f77bcf86cd799439014",
"job": "507f1f77bcf86cd799439015",
"status": "completed",
"description": "Offer accepted - $105 transferred to admin",
"completedAt": "2026-01-28T12:30:00.000Z",
"createdAt": "2026-01-28T12:30:00.000Z"
}
],
"pagination": {
"currentPage": 1,
"totalPages": 3,
"totalTransactions": 45,
"limit": 20
}
}
}Flutter Implementation:
Future<Map<String, dynamic>> getTransactions({
int page = 1,
int limit = 20,
String? type,
}) async {
final queryParams = {
'page': page.toString(),
'limit': limit.toString(),
if (type != null) 'type': type,
};
final uri = Uri.parse('$baseUrl/api/wallet/transactions')
.replace(queryParameters: queryParams);
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['data'];
} else {
throw Exception('Failed to load transactions');
}
}Transaction Types:
deposit: Money added via Stripewithdrawal: Money withdrawn to bankwallet_transfer: Money moved between users (offer acceptance)contractor_payout: Payment from admin to contractor (job completion)refund: Money refunded to customer
The deposit flow uses Stripe Checkout hosted pages, which means users complete payment in their browser, not in-app.
# pubspec.yaml
dependencies:
url_launcher: ^6.2.0 # For opening browser
http: ^1.1.0 # For API callsimport 'package:url_launcher/url_launcher.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class DepositService {
final String baseUrl;
final String accessToken;
DepositService({required this.baseUrl, required this.accessToken});
Future<void> initiateDeposit(double amount) async {
try {
// Step 1: Request deposit from backend
final response = await http.post(
Uri.parse('$baseUrl/api/wallet/deposit'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: json.encode({'amount': amount}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body)['data'];
final checkoutUrl = data['checkoutUrl'];
// Step 2: Open Stripe Checkout in browser
await _openCheckout(checkoutUrl);
// Step 3: Wait for user to return
// You can show a waiting screen here
} else {
throw Exception('Failed to create deposit');
}
} catch (e) {
throw Exception('Deposit error: $e');
}
}
Future<void> _openCheckout(String url) async {
final Uri uri = Uri.parse(url);
// Opens in external browser (NOT in-app)
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication, // IMPORTANT: Use external browser
);
} else {
throw Exception('Could not launch checkout URL');
}
}
}After user returns from browser, check if payment was completed:
Option 1: Polling Wallet Balance
Future<void> waitForDepositCompletion() async {
// Poll every 3 seconds for up to 2 minutes
for (int i = 0; i < 40; i++) {
await Future.delayed(Duration(seconds: 3));
final wallet = await getWallet();
// Check if balance increased
// Store previous balance before initiating deposit for comparison
}
}Option 2: Push Notifications (Recommended)
// Backend sends push notification when webhook confirms payment
// Listen for notification in Flutter
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.data['type'] == 'deposit_completed') {
// Refresh wallet balance
// Update UI
}
});Contractors need to set up Stripe Connect accounts to receive payouts:
Onboarding Link Endpoint: POST /api/wallet/stripe/onboard
Future<String> getStripeOnboardingLink() async {
final response = await http.post(
Uri.parse('$baseUrl/api/wallet/stripe/onboard'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body)['data'];
return data['onboardingUrl'];
} else {
throw Exception('Failed to get onboarding link');
}
}
// Usage
final onboardingUrl = await getStripeOnboardingLink();
await launchUrl(
Uri.parse(onboardingUrl),
mode: LaunchMode.externalApplication,
);Check Onboarding Status: GET /api/wallet/stripe/status
Future<Map<String, dynamic>> getStripeStatus() async {
final response = await http.get(
Uri.parse('$baseUrl/api/wallet/stripe/status'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
return json.decode(response.body)['data'];
// Returns: { "isConnected": true/false, "canReceivePayouts": true/false }
} else {
throw Exception('Failed to get Stripe status');
}
}{
"status": 400,
"message": "Insufficient balance",
"data": null,
"errors": [
{
"field": "amount",
"message": "Required: $105, Available: $50"
}
]
}| Status | Meaning | Example |
|---|---|---|
| 400 | Bad Request | Invalid input, validation failed |
| 401 | Unauthorized | Missing or invalid token |
| 403 | Forbidden | Insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 500 | Server Error | Internal server error |
Future<Map<String, dynamic>> apiCall() async {
try {
final response = await http.post(...);
final data = json.decode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return data['data'];
} else {
// Handle error response
throw ApiException(
statusCode: response.statusCode,
message: data['message'] ?? 'Unknown error',
errors: data['errors'],
);
}
} on SocketException {
throw NetworkException('No internet connection');
} on FormatException {
throw ParseException('Invalid response format');
} catch (e) {
throw Exception('Unexpected error: $e');
}
}
// Custom exception classes
class ApiException implements Exception {
final int statusCode;
final String message;
final List<dynamic>? errors;
ApiException({
required this.statusCode,
required this.message,
this.errors,
});
}Insufficient Balance:
try {
await sendOffer(...);
} on ApiException catch (e) {
if (e.statusCode == 400 && e.message.contains('Insufficient balance')) {
// Show deposit dialog
showDepositDialog();
}
}Wallet Frozen:
try {
await depositMoney(100);
} on ApiException catch (e) {
if (e.message.contains('frozen')) {
// Show contact support dialog
showContactSupportDialog();
}
}// Store tokens securely
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
final storage = FlutterSecureStorage();
Future<void> saveTokens(String accessToken, String refreshToken) async {
await storage.write(key: 'access_token', value: accessToken);
await storage.write(key: 'refresh_token', value: refreshToken);
}
Future<String?> getAccessToken() async {
return await storage.read(key: 'access_token');
}
}// Centralized API service
class ApiService {
final String baseUrl;
final TokenStorage tokenStorage;
ApiService({required this.baseUrl, required this.tokenStorage});
Future<Map<String, String>> _getHeaders() async {
final token = await tokenStorage.getAccessToken();
return {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
}
Future<http.Response> get(String endpoint) async {
return http.get(
Uri.parse('$baseUrl$endpoint'),
headers: await _getHeaders(),
);
}
Future<http.Response> post(String endpoint, Map<String, dynamic> body) async {
return http.post(
Uri.parse('$baseUrl$endpoint'),
headers: await _getHeaders(),
body: json.encode(body),
);
}
}// Using Provider for wallet state
import 'package:flutter/foundation.dart';
class WalletProvider with ChangeNotifier {
double _balance = 0.0;
bool _isLoading = false;
double get balance => _balance;
bool get isLoading => _isLoading;
Future<void> loadWallet(WalletService service) async {
_isLoading = true;
notifyListeners();
try {
final wallet = await service.getWallet();
_balance = wallet['balance'];
} catch (e) {
// Handle error
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> refreshBalance(WalletService service) async {
final wallet = await service.getWallet();
_balance = wallet['balance'];
notifyListeners();
}
}// Show loading indicator during API calls
class PaymentScreen extends StatefulWidget {
@override
_PaymentScreenState createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State<PaymentScreen> {
bool _isProcessing = false;
Future<void> _sendOffer() async {
setState(() {
_isProcessing = true;
});
try {
await offerService.sendOffer(...);
// Show success
} catch (e) {
// Show error
} finally {
setState(() {
_isProcessing = false;
});
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isProcessing ? null : _sendOffer,
child: _isProcessing
? CircularProgressIndicator()
: Text('Send Offer'),
);
}
}Future<void> sendOffer({
required double amount,
required String timeline,
required String description,
}) async {
// Client-side validation
if (amount < 10 || amount > 10000) {
throw Exception('Amount must be between \$10 and \$10,000');
}
if (timeline.length < 1 || timeline.length > 100) {
throw Exception('Timeline must be 1-100 characters');
}
if (description.length < 10 || description.length > 1000) {
throw Exception('Description must be 10-1000 characters');
}
// Check balance
final wallet = await getWallet();
final totalCharge = amount * 1.05; // Include 5% platform fee
if (wallet['balance'] < totalCharge) {
throw Exception('Insufficient balance');
}
// Make API call
await offerService.sendOffer(...);
}class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// User returned from browser after Stripe checkout
// Refresh wallet balance
walletProvider.refreshBalance(walletService);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(...);
}
}For API integration questions:
- Backend API documentation:
doc/payment/1.MAIN-REFERENCE.md - Backend implementation:
doc/payment/2.BACKEND_IMPLEMENTATION.md - Technical support: [email protected]
Document Version: 2.0.0
Last Updated: January 28, 2026
Status: ✅ Complete and Production Ready