Skip to content

Transaction Lifecycle

Complete end-to-end transaction flow from initiation to settlement in the FLUID Network.

Overview

This guide provides a comprehensive walkthrough of the debit transaction lifecycle, covering all stages from initial API request through to final settlement. Understanding this flow is critical for implementing robust integrations that handle all transaction states correctly.

Transaction States

FLUID Network uses a state machine to manage transaction lifecycle with clear transitions and rules:

StatusDescriptionWebhook EventRetryableTerminal
pendingTransaction created, queued for bank processing-NoNo
processingSent to bank, awaiting customer approval-NoNo
completedSuccessfully approved and debitedtransaction.completedNoNo
failedRejected, declined, or error occurredtransaction.failedYesNo
reversedPreviously completed transaction reversedtransaction.reversedNoYes

INFO

Asynchronous Processing: FLUID Network uses background job processing for bank requests. The API responds immediately (<200ms) with pending status while bank processing happens asynchronously.

Complete Transaction Flow

State Transition Diagram

Status Transition Rules

Valid Transitions

From StatusTo StatusTriggerNotes
pendingprocessingBackground job processes bank requestAutomatic transition
pendingfailedValidation error or immediate bank rejectionInstant failure
processingcompletedCustomer approves via USSD/AppSuccess path
processingfailedCustomer denies, timeout, or bank errorFailure path
completedreversedReversal request (rare)Manual operation
failedpendingManual retry or automatic retry logicAllows retry

Invalid Transitions

DANGER

These transitions are not allowed and will result in a 422 Unprocessable Entity error:

  • completedpending (cannot un-complete)
  • completedfailed (cannot fail after completion)
  • reversed → any status (terminal state)
  • processingpending (cannot go backward)

Phase-by-Phase Breakdown

Phase 1: Transaction Initiation

Duration: <200ms (synchronous)

http
POST /api/v1/payment-providers/debit-requests/charge
Authorization: Token YOUR_API_KEY
Content-Type: application/json

Request Body:

json
{
  "phone_number": "+233241234567",
  "bank_identifier": "ECO",
  "amount": 100.00,
  "currency": "GHS",
  "narration": "Payment for Order #12345",
  "partner_reference": "order_12345",
  "metadata": {
    "order_id": "12345",
    "customer_email": "john@example.com"
  }
}

Immediate Response:

json
{
  "success": true,
  "reference": "FLU20250112ABC123",
  "status": "pending",
  "response": {
    "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "pending",
    "timestamp": "2025-01-12T14:30:00Z",
    "bank": {
      "name": "Example Bank Ghana",
      "identifier": "EXB",
      "country_code": "GH"
    }
  }
}

What Happens:

  1. FLUID validates request parameters
  2. Creates transaction record with pending status
  3. Queues BankDebitRequestJob for background processing
  4. Returns immediately with transaction reference
  5. Background job processes bank request asynchronously

TIP

Why Async? Synchronous bank API calls can take 2-5 seconds. Async processing provides:

  • 90% faster API response times
  • 10x better scalability
  • 100% reliability (API succeeds even if bank temporarily fails)
  • Dramatically improved user experience

Phase 2: Bank Processing

Duration: 1-30 seconds (asynchronous)

ruby
# Background job processes bank request
BankDebitRequestJob.perform_later(transaction_id)

What Happens:

  1. Background job picks up transaction
  2. Selects appropriate bank connector
  3. Sends debit request to bank API
  4. Bank accepts request and sends notification to customer
  5. Transaction status updates to processing
  6. Job completes successfully

Potential Errors:

  • Bank API timeout → Automatic retry with exponential backoff
  • Bank API error → 3-5 retry attempts before marking failed
  • Network issues → Retry logic preserves transaction state

Phase 3: Customer Approval

Duration: 30 seconds - 5 minutes (variable)

Customer Experience:

  1. Receives USSD prompt or mobile app notification
  2. Reviews transaction details:
    • Merchant name
    • Amount to debit
    • Account to debit from
  3. Enters PIN or biometric approval
  4. Confirms or cancels transaction

Approval Methods:

MethodDescriptionTypical Duration
ussdUSSD push notification30s - 2min
mobile_appBank mobile app approval1-3min
webBank web portal approval2-5min
autoPre-authorized automatic debit<5s

Phase 4: Status Update & Webhook

Duration: <1 second

When the bank updates the transaction status, FLUID automatically triggers a webhook notification:

Webhook Payload (Completed):

json
{
  "event": "transaction.completed",
  "event_id": "evt_a1b2c3d4e5f6",
  "timestamp": "2025-01-12T14:32:30Z",
  "api_version": "v1",
  "data": {
    "transaction": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "reference": "FLU20250112ABC123",
      "partner_reference": "order_12345",
      "status": "completed",
      "amount": 100.00,
      "currency": "GHS",
      "narration": "Payment for Order #12345",
      "approval_method": "ussd",
      "bank_reference": "EXB-2025-001234",
      "created_at": "2025-01-12T14:30:00Z",
      "completed_at": "2025-01-12T14:32:30Z"
    },
    "customer": {
      "phone_number": "+233241234567",
      "name": "John Doe"
    },
    "bank": {
      "name": "Example Bank Ghana",
      "identifier": "EXB",
      "country_code": "GH"
    },
    "previous_status": "processing"
  }
}

Webhook Payload (Failed):

json
{
  "event": "transaction.failed",
  "event_id": "evt_b2c3d4e5f6g7",
  "timestamp": "2025-01-12T14:32:30Z",
  "api_version": "v1",
  "data": {
    "transaction": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "reference": "FLU20250112ABC123",
      "partner_reference": "order_12345",
      "status": "failed",
      "amount": 100.00,
      "currency": "GHS",
      "error_message": "Insufficient funds",
      "created_at": "2025-01-12T14:30:00Z",
      "completed_at": null
    },
    "customer": {
      "phone_number": "+233241234567",
      "name": "John Doe"
    },
    "bank": {
      "name": "Example Bank Ghana",
      "identifier": "EXB",
      "country_code": "GH"
    },
    "previous_status": "processing"
  }
}

WARNING

Webhook Handling Best Practices:

  • Always verify HMAC signature (see Signature Verification)
  • Return 200 OK within 5 seconds to acknowledge receipt
  • Process webhook asynchronously in your system
  • Implement idempotency using event_id to avoid duplicate processing

Phase 5: Settlement (Daily Batch)

Duration: Processed daily at 00:00 UTC

What Happens:

  1. Daily job runs at 00:00 UTC
  2. Identifies all completed transactions from previous days
  3. Groups transactions by payment partner and currency
  4. Creates SettlementBatch records
  5. Generates settlement line items
  6. Calculates fees, net amounts, and totals
  7. Processes batches through bank settlement APIs
  8. Generates settlement reports for partners

Settlement Timeline:

DayActionDescription
Day 0Transaction completesFunds debited from customer
Day 1Settlement batch createdIncluded in next day's batch
Day 2Settlement processedFunds transferred to partner account

INFO

Automatic Settlement: All completed transactions automatically create settlement records. No manual intervention required.

Polling vs Webhooks

Polling Transaction Status

You can poll transaction status using the query endpoint:

http
GET /api/v1/payment-providers/debit-requests/{reference}
Authorization: Token YOUR_API_KEY

Response:

json
{
  "success": true,
  "data": {
    "id": 12345,
    "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "reference": "FLU20250112ABC123",
    "status": "completed",
    "amount": 100.00,
    "currency": "GHS",
    "created_at": "2025-01-12T14:30:00Z",
    "completed_at": "2025-01-12T14:32:30Z"
  }
}

Polling Strategy:

bash
# Poll transaction status every 10 seconds for up to 2 minutes
reference="FLU20250112ABC123"
max_attempts=12
interval=10

for ((i=1; i<=max_attempts; i++)); do
  response=$(curl -s "https://api.fluid-network.com/api/v1/payment-providers/debit-requests/${reference}" \
    -H "Authorization: Token YOUR_API_KEY")
  
  status=$(echo "$response" | jq -r '.data.status')
  echo "Attempt $i: Status = $status"
  
  # Check if terminal state reached
  if [[ "$status" == "completed" || "$status" == "failed" || "$status" == "reversed" ]]; then
    echo "Final state reached: $status"
    echo "$response" | jq '.'
    exit 0
  fi
  
  # Wait before next attempt
  sleep $interval
done

echo "Polling timeout after 2 minutes"
exit 1

Implementation Notes:

  • Max attempts: 12 attempts × 10 seconds = 2 minutes maximum
  • Interval: Poll every 10 seconds (don't poll more frequently - rate limit risk)
  • Terminal states: completed, failed, or reversed indicate transaction is done
  • Timeout handling: After max attempts, consider transaction timed out
  • Implementation pattern:
    • Make HTTP GET request to status endpoint
    • Parse JSON response to extract status field
    • Check if status is in terminal state list
    • If terminal, return result and exit loop
    • If not terminal, wait interval seconds before next attempt
    • After max attempts, throw timeout error
  • Language-specific approaches:
    • JavaScript/Node.js: Use setTimeout() in loop, fetch() or axios for HTTP
    • Python: Use time.sleep() in loop, requests library for HTTP
    • PHP: Use sleep() in loop, curl or Guzzle for HTTP
    • Ruby: Use sleep() in loop, RestClient or Net::HTTP for HTTP

DANGER

Polling Anti-Patterns:

  • ❌ Polling more frequently than every 10 seconds (rate limit violations)
  • ❌ Polling indefinitely without timeout (resource waste)
  • ❌ Using polling instead of webhooks for production (poor scalability)

TIP

Best Practice: Use webhooks for production systems. Webhooks provide:

  • Real-time updates (no polling delay)
  • Better scalability (no wasted API calls)
  • Lower costs (reduced rate limit consumption)
  • Simplified code (event-driven architecture)

See Webhooks Overview for complete webhook implementation guide.

Error Scenarios & Handling

Common Error Scenarios

ScenarioStatusError MessageRecovery
Insufficient fundsfailed"Insufficient funds"Ask customer to fund account, retry
Customer declinedfailed"Customer declined transaction"No retry, consider alternative payment
Timeoutfailed"Transaction timeout"Retry with new transaction
Invalid accountfailed"Invalid account number"Verify account details, update
Bank system errorfailed"Bank system unavailable"Retry after 5 minutes
Network errorpending → retry"Network error"Automatic retry by background job

Error Handling Best Practices

Retryable vs Non-Retryable Errors:

bash
# Example: Check transaction and determine retry strategy
reference="FLU20250112ABC123"
response=$(curl -s "https://api.fluid-network.com/api/v1/payment-providers/debit-requests/${reference}" \
  -H "Authorization: Token YOUR_API_KEY")

status=$(echo "$response" | jq -r '.data.status')
error_msg=$(echo "$response" | jq -r '.data.error_message // empty')

if [ "$status" == "completed" ]; then
  echo "✅ Transaction successful"
  # Process success: mark order as paid, send receipt, etc.
  
elif [ "$status" == "failed" ]; then
  echo "❌ Transaction failed: $error_msg"
  
  # Check if retryable
  case "$error_msg" in
    *"timeout"*|*"unavailable"*|*"Network error"*)
      echo "⏱️  Retryable error - schedule retry after 5 minutes"
      ;;
    *"Insufficient funds"*|*"declined"*)
      echo "🚫 Non-retryable error - notify customer, offer alternative payment"
      ;;
  esac
fi

Implementation Notes:

  • Success path (completed):
    • Mark order/invoice as paid in your system
    • Send receipt/confirmation to customer
    • Update inventory, fulfill order
    • Log successful payment
  • Retryable failures:
    • "Transaction timeout" → Retry with new transaction after 5 minutes
    • "Bank system unavailable" → Retry after 5-10 minutes
    • "Network error" → Automatic retry by FLUID background jobs
    • Schedule retry with exponential backoff
  • Non-retryable failures:
    • "Insufficient funds" → Ask customer to fund account, don't retry automatically
    • "Customer declined" → Offer alternative payment method
    • "Invalid account" → Verify and update account details
    • Notify customer of specific failure reason
  • Processing state:
    • Transaction awaiting customer approval
    • Notify customer to check phone for USSD prompt
    • Don't mark as failed yet - customer may still approve
  • Error handling pattern:
    • Use switch/case statement on transaction status
    • For failed status, inspect error message to categorize
    • Implement different recovery strategies per error type
    • Log all transaction outcomes for debugging
    • Maintain idempotency to prevent duplicate charges on retry

Transaction Timeouts

Default Timeouts

PhaseTimeoutBehavior
API Request30 secondsReturns 504 Gateway Timeout
Bank Processing10 minutesTransaction marked as failed
Customer Approval5 minutesTransaction marked as failed (timeout)
Webhook Delivery30 secondsRetries with exponential backoff

Handling Timeouts

WARNING

Important: Transactions that timeout during customer approval will be automatically marked as failed with error message "Transaction timeout". You can retry these transactions with a new API request.

Complete Implementation Example

This example demonstrates a complete transaction flow from initiation through status checking:

bash
#!/bin/bash
# complete-transaction-flow.sh
# Complete FLUID Network debit transaction implementation

API_KEY="your_api_key_here"
BASE_URL="https://api.fluid-network.com/api/v1/payment-providers"

# Step 1: Initiate transaction
echo "=== Step 1: Initiating Transaction ==="
response=$(curl -s -X POST "$BASE_URL/debit-requests/charge" \
  -H "Authorization: Token $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+233241234567",
    "bank_identifier": "ECO",
    "amount": 100.00,
    "currency": "GHS",
    "narration": "Payment for Order #12345",
    "partner_reference": "order_12345",
    "metadata": {
      "customer_email": "john@example.com"
    }
  }')

# Extract reference and status
reference=$(echo "$response" | jq -r '.reference')
status=$(echo "$response" | jq -r '.status')
uuid=$(echo "$response" | jq -r '.response.uuid')

echo "Transaction initiated:"
echo "  Reference: $reference"
echo "  UUID: $uuid"
echo "  Status: $status"

# Step 2: Poll for completion (max 2 minutes)
echo ""
echo "=== Step 2: Polling for Completion ==="
max_attempts=12
interval=10

for ((i=1; i<=max_attempts; i++)); do
  echo "Attempt $i of $max_attempts..."
  
  status_response=$(curl -s "$BASE_URL/debit-requests/$reference" \
    -H "Authorization: Token $API_KEY")
  
  current_status=$(echo "$status_response" | jq -r '.data.status')
  echo "  Current status: $current_status"
  
  # Check for terminal state
  if [[ "$current_status" == "completed" ]]; then
    echo ""
    echo "✅ Transaction completed successfully!"
    echo "$status_response" | jq '.data'
    exit 0
  elif [[ "$current_status" == "failed" ]]; then
    error_msg=$(echo "$status_response" | jq -r '.data.error_message')
    echo ""
    echo "❌ Transaction failed: $error_msg"
    
    # Check if retryable
    case "$error_msg" in
      *"timeout"*|*"unavailable"*)
        echo "⏱️  This is a retryable error. You can create a new transaction."
        ;;
      *"Insufficient"*|*"declined"*)
        echo "🚫 This is a permanent failure. Customer action required."
        ;;
    esac
    exit 1
  elif [[ "$current_status" == "reversed" ]]; then
    echo ""
    echo "↩️  Transaction was reversed"
    exit 1
  fi
  
  # Wait before next attempt (unless this is the last attempt)
  if [ $i -lt $max_attempts ]; then
    sleep $interval
  fi
done

echo ""
echo "⏱️  Transaction still processing after 2 minutes"
echo "Consider implementing webhook notifications instead of polling"
exit 1

Implementation Guide:

This bash script demonstrates the complete flow. Here's how to implement it in your language:

1. Create HTTP Client Wrapper Class

FluidDebitTransaction class/module with:
- Constructor accepting API key
- Base URL configuration
- Common headers setup (Authorization, Content-Type)

2. Implement Transaction Initiation Method

initiate(params):
  - Validate required params: phone_number, bank_identifier, amount
  - Set defaults: currency='GHS', metadata={}
  - POST to /debit-requests/charge
  - Return structured response: {success, reference, uuid, status}
  - Handle HTTP errors gracefully

3. Implement Status Query Method

getStatus(reference):
  - GET /debit-requests/{reference}
  - Parse JSON response
  - Return transaction data object
  - Throw error if request fails

4. Implement Polling Method

pollUntilComplete(reference, options):
  - Default: max_attempts=12, interval=10 seconds
  - Loop up to max_attempts times:
    - Call getStatus(reference)
    - Check if status in ['completed', 'failed', 'reversed']
    - If terminal state, return transaction
    - If not terminal, wait interval seconds
  - After max_attempts, throw timeout error

5. Implement Error Handler

handleTransactionResult(transaction):
  - Switch on transaction.status:
    - 'completed': Mark order paid, send receipt
    - 'failed': Check error_message for retry strategy
      - Retryable: Schedule retry after delay
      - Non-retryable: Notify customer, offer alternatives
    - 'processing': Notify customer to check phone
    - 'reversed': Handle refund/reversal logic

6. Usage Pattern

1. Create client instance with API key
2. Call initiate() with transaction params
3. Store reference for future queries
4. Either:
   a) Poll with pollUntilComplete() (development/testing)
   b) Wait for webhook (production - recommended)
5. Handle final transaction state appropriately

Language-Specific Notes:

JavaScript/Node.js:

  • Use fetch() or axios for HTTP requests
  • Use async/await for asynchronous operations
  • Use setTimeout() with Promise for polling delays
  • Use ES6 classes for wrapper implementation

Python:

  • Use requests library for HTTP
  • Use time.sleep() for polling delays
  • Use classes or functions based on preference
  • Type hints recommended for better IDE support

PHP:

  • Use Guzzle or native curl for HTTP
  • Use sleep() for polling delays
  • Use classes for clean encapsulation
  • Consider using typed properties (PHP 7.4+)

Ruby:

  • Use RestClient or Net::HTTP for requests
  • Use sleep() for polling delays
  • Ruby classes with idiomatic method naming
  • Use symbols for hash keys

Production Considerations:

  • ⚠️ Use webhooks instead of polling for production systems
  • Store reference and uuid in your database
  • Implement proper logging for all API interactions
  • Use exponential backoff for retry logic
  • Implement idempotency checks using partner_reference
  • Handle webhook signature verification (see webhook docs)
  • Consider using background jobs for async processing
  • Monitor transaction success/failure rates

Best Practices Summary

Transaction Lifecycle Best Practices

DO:

  • Use webhooks for production systems instead of polling
  • Implement idempotency using partner_reference to prevent duplicates
  • Store transaction reference and uuid for future queries
  • Handle all transaction states (pending, processing, completed, failed)
  • Implement retry logic for retryable errors
  • Verify webhook signatures before processing
  • Return 200 OK from webhook endpoints within 5 seconds
  • Use proper error handling and logging
  • Test all scenarios in sandbox environment

DON'T:

  • Poll more frequently than every 10 seconds
  • Poll indefinitely without timeout
  • Assume transactions will complete immediately
  • Ignore processing state (customer still deciding)
  • Retry non-retryable errors (insufficient funds, customer declined)
  • Process duplicate webhooks (check event_id)
  • Block webhook responses with slow processing
  • Expose sensitive data in metadata