Overview
Proper error handling is crucial for providing a smooth user experience during the checkout process. The Hubtel Merchant Checkout SDK provides several ways to detect and handle errors, from payment failures to network issues.Payment Status Errors
TheUnifiedCheckoutPaymentStatus enum includes status values that indicate various error conditions:
enum UnifiedCheckoutPaymentStatus {
paymentFailed, // Payment transaction failed
paymentSuccess, // Payment succeeded
pending, // Payment is pending
unknown, // Status cannot be determined
userCancelledPayment // User cancelled the checkout
}
Error Status Values
paymentFailed
The payment transaction was attempted but failed due to insufficient funds, declined card, or other payment issues.
userCancelledPayment
The user closed the checkout screen without completing the payment.
unknown
The payment status could not be determined. This may occur when the user cancels after initiating payment without checking the status.
pending
The payment is being processed. While not an error, it requires special handling to inform the user.
Handling CheckoutCompletionStatus
Always check thestatus property of the CheckoutCompletionStatus object returned by the checkout:
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CheckoutScreen(
purchaseInfo: purchaseInfo,
configuration: hubtelConfig,
),
),
);
if (result is CheckoutCompletionStatus) {
switch (result.status) {
case UnifiedCheckoutPaymentStatus.paymentSuccess:
handleSuccess(result);
break;
case UnifiedCheckoutPaymentStatus.paymentFailed:
handleFailure(result);
break;
case UnifiedCheckoutPaymentStatus.userCancelledPayment:
handleCancellation();
break;
case UnifiedCheckoutPaymentStatus.pending:
handlePending(result);
break;
case UnifiedCheckoutPaymentStatus.unknown:
handleUnknownStatus(result);
break;
}
}
Common Payment Failure Scenarios
1. Insufficient Funds
void handleInsufficientFunds(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Insufficient Funds'),
content: const Text(
'Your account does not have enough balance to complete this transaction. '
'Please add funds and try again.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
2. Card Declined
void handleCardDeclined(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Card Declined'),
content: const Text(
'Your card was declined. Please check your card details '
'or try a different payment method.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Try Again'),
),
],
),
);
}
3. Network Error
void handleNetworkError(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Network Error'),
content: const Text(
'Unable to connect to the payment service. '
'Please check your internet connection and try again.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Retry'),
),
],
),
);
}
Complete Error Handling Example
import 'package:flutter/material.dart';
import 'package:hubtel_merchant_checkout_sdk/hubtel_merchant_checkout_sdk.dart';
import 'package:uuid/uuid.dart';
class RobustCheckout extends StatefulWidget {
const RobustCheckout({Key? key}) : super(key: key);
@override
State<RobustCheckout> createState() => _RobustCheckoutState();
}
class _RobustCheckoutState extends State<RobustCheckout> {
bool isProcessing = false;
String? errorMessage;
Future<void> initiateCheckout() async {
setState(() {
isProcessing = true;
errorMessage = null;
});
try {
final hubtelConfig = HubtelCheckoutConfiguration(
merchantApiKey: "YOUR_BASE64_ENCODED_API_KEY",
merchantID: "YOUR_MERCHANT_ID",
callbackUrl: "https://your-callback-url.com",
);
final purchaseInfo = PurchaseInfo(
amount: 100.0,
customerPhoneNumber: '0541234567',
clientReference: const Uuid().v4(),
purchaseDescription: 'Product Purchase',
);
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CheckoutScreen(
purchaseInfo: purchaseInfo,
configuration: hubtelConfig,
themeConfig: ThemeConfig(primaryColor: Colors.teal),
),
),
);
setState(() => isProcessing = false);
if (result is CheckoutCompletionStatus) {
handleCheckoutResult(result);
} else {
// User dismissed without completing checkout
setState(() {
errorMessage = 'Checkout was cancelled';
});
}
} catch (e) {
setState(() {
isProcessing = false;
errorMessage = 'An unexpected error occurred: $e';
});
showErrorDialog('Unexpected Error', e.toString());
}
}
void handleCheckoutResult(CheckoutCompletionStatus status) {
switch (status.status) {
case UnifiedCheckoutPaymentStatus.paymentSuccess:
handlePaymentSuccess(status);
break;
case UnifiedCheckoutPaymentStatus.paymentFailed:
handlePaymentFailed(status);
break;
case UnifiedCheckoutPaymentStatus.userCancelledPayment:
handleUserCancelled();
break;
case UnifiedCheckoutPaymentStatus.pending:
handlePaymentPending(status);
break;
case UnifiedCheckoutPaymentStatus.unknown:
handleUnknownStatus(status);
break;
}
}
void handlePaymentSuccess(CheckoutCompletionStatus status) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.check_circle, color: Colors.green, size: 28),
SizedBox(width: 10),
Text('Payment Successful'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Transaction ID: ${status.transactionId}'),
if (status.paymentChannel != null)
Text('Payment Channel: ${status.paymentChannel}'),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
// Navigate to success screen or update order status
},
child: const Text('OK'),
),
],
),
);
}
void handlePaymentFailed(CheckoutCompletionStatus status) {
setState(() {
errorMessage = 'Payment failed - Transaction ID: ${status.transactionId}';
});
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.error, color: Colors.red, size: 28),
SizedBox(width: 10),
Text('Payment Failed'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your payment could not be completed. '
'Please try again or use a different payment method.',
),
const SizedBox(height: 10),
Text(
'Transaction ID: ${status.transactionId}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
initiateCheckout(); // Retry
},
child: const Text('Try Again'),
),
],
),
);
}
void handleUserCancelled() {
setState(() {
errorMessage = 'You cancelled the payment';
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Payment was cancelled'),
backgroundColor: Colors.orange,
),
);
}
void handlePaymentPending(CheckoutCompletionStatus status) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.access_time, color: Colors.orange, size: 28),
SizedBox(width: 10),
Text('Payment Pending'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your payment is being processed. '
'You will receive a notification once it is completed.',
),
const SizedBox(height: 10),
Text(
'Transaction ID: ${status.transactionId}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
void handleUnknownStatus(CheckoutCompletionStatus status) {
setState(() {
errorMessage = 'Payment status unknown - Transaction ID: ${status.transactionId}';
});
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.help, color: Colors.orange, size: 28),
SizedBox(width: 10),
Text('Status Unknown'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'We could not verify your payment status. '
'Please contact support with your transaction ID.',
),
const SizedBox(height: 10),
Text(
'Transaction ID: ${status.transactionId}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// Contact support or verify transaction
},
child: const Text('Contact Support'),
),
],
),
);
}
void showErrorDialog(String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Robust Checkout')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red.shade700),
const SizedBox(width: 10),
Expanded(
child: Text(
errorMessage!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
ElevatedButton(
onPressed: isProcessing ? null : initiateCheckout,
child: isProcessing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Start Checkout'),
),
],
),
),
);
}
}
Best Practices
Always Handle All Cases
Handle all possible
UnifiedCheckoutPaymentStatus values in your switch statement.Store Transaction IDs
Save transaction IDs for failed payments to help with support and reconciliation.
Provide Clear Messages
Show user-friendly error messages that explain what went wrong and what to do next.
Enable Retry
Allow users to easily retry failed payments without re-entering all information.
Error Logging
Log errors for debugging and monitoring:import 'dart:developer' as developer;
void logPaymentError(CheckoutCompletionStatus status) {
developer.log(
'Payment failed',
name: 'PaymentError',
error: {
'status': status.status.toString(),
'transactionId': status.transactionId,
'paymentChannel': status.paymentChannel,
'paymentType': status.paymentType,
},
);
}
Consider integrating error tracking services like Sentry or Firebase Crashlytics to monitor payment failures in production.
Server-Side Verification
Always verify payment status on your backend using Hubtel’s APIs. Client-side status should be treated as preliminary.
Future<void> verifyPaymentOnBackend(String transactionId) async {
try {
final response = await http.post(
Uri.parse('https://your-backend.com/verify-payment'),
body: jsonEncode({'transactionId': transactionId}),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Process verified payment status
}
} catch (e) {
developer.log('Backend verification failed: $e');
}
}
Handling SDK Internal Errors
The SDK may show error dialogs internally for issues like:- Network connectivity problems
- Invalid configuration
- API errors
widget_extensions.dart):
// The SDK shows error dialogs internally
widget.showCheckoutErrorDialog(
context: context,
message: 'Something went wrong while configuring business',
);
Monitor your app’s error logs to identify patterns in payment failures and improve the checkout experience.
Next Steps
- Learn about handling payment completion
- Explore saved cards functionality
- Customize the checkout UI to match your brand