Skip to main content
Out-of-Band (OOB) authentication provides an extra layer of security for online card transactions by requiring cardholder verification through a separate channel. The Issuing API provides webhook-based OOB integration that allows you to build seamless, in-app approval experiences while maintaining security and compliance.

Overview

When a cardholder performs an online transaction that requires OOB verification, UTGL generates a secure, time-sensitive approval URL. This URL is delivered to your webhook endpoint, allowing you to present it to the cardholder within your application. After the cardholder completes the verification, a terminal webhook event communicates the final outcome.

Key Benefits

  • Enhanced Security - Reduces fraud and chargebacks through cardholder verification
  • Regulatory Compliance - Meets Strong Customer Authentication (SCA) requirements
  • Seamless UX - In-app approval flow keeps users within your application
  • Real-time Updates - Webhook events provide immediate status notifications

How OOB Works

The OOB (Out-of-Band) flow involves multiple parties working together to verify the transaction:

Transaction Lifecycle

  1. Transaction Initiation - Cardholder initiates an online payment
  2. OOB Detection - UTGL system determines if OOB verification is required
  3. Challenge Initiated - Approval URL generated and sent via webhook
  4. User Verification - Cardholder reviews and approves/declines on secure page
  5. Completion - Terminal webhook sent with final outcome

Webhook Events

The OOB integration uses four webhook events to manage the complete transaction lifecycle:
EventWhen SentPurpose
cardaccount.transaction.challenge.initiatedWhen OOB verification is requiredContains approval URL and transaction details
cardaccount.transaction.challenge.succeededCardholder approves transactionConfirms successful verification
cardaccount.transaction.challenge.declinedCardholder declines transactionIndicates active rejection
cardaccount.transaction.challenge.failedChallenge expires or system errorIndicates failure or timeout

Integration Guide

Prerequisites

Before integrating OOB, ensure you have:
  1. Webhook endpoint configured - Secure HTTPS endpoint to receive events
  2. Webhook signature verification - Validate incoming webhook requests
  3. Event processing system - Handle webhook events reliably
  4. User interface - Way to present approval URL to cardholders
See Webhooks Overview for webhook setup instructions.

Step 1: Handle Challenge Initiated Event

When a transaction requires OOB verification, you’ll receive a challenge.initiated webhook event.

Event Structure

{
  "event": "cardaccount.transaction.challenge.initiated",
  "id": "evt_1a2b3c4d5e6f7g8h9i0j",
  "time": "2025-09-12T11:50:00.000Z",
  "data": {
    "url": "https://approve.utgl.io/tx/eyJhbg...GciOiJIUzI1NiJ9",
    "expiresAt": "2025-09-12T11:55:00.000Z",
    "cardAccountId": "a857911b-d9c3-491a-98a7-a27c071d932d",
    "cardId": "438f4574-8d75-4938-8667-e626d181da19",
    "merchant": "Example Online Store",
    "currency": "USD",
    "amount": 125.50
  }
}

Event Data Fields

FieldTypeDescription
urlstringUnique, time-sensitive URL for cardholder approval. Must be presented to cardholder immediately.
expiresAtstring (ISO 8601)Timestamp when the approval URL expires (typically 5 minutes from generation)
cardAccountIdstring (UUID)ID of the card account associated with the transaction
cardIdstring (UUID)ID of the card used for the transaction
merchantstringMerchant name for display to cardholder
currencystringTransaction currency (ISO 4217 code)
amountnumberTransaction amount

Webhook Handler Example

// Express.js example
app.post('/webhooks/utgl', async (req, res) => {
  // Verify webhook signature
  const signature = req.headers['x-utgl-signature'];
  if (!verifyWebhookSignature(req.body, signature)) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;

  if (event.event === 'cardaccount.transaction.challenge.initiated') {
    const { url, expiresAt, cardAccountId, cardId, merchant, currency, amount } = event.data;
    
    // Store challenge state (for idempotency)
    await storeChallengeState(event.id, {
      url,
      expiresAt,
      cardAccountId,
      cardId,
      merchant,
      currency,
      amount
    });
    
    // Notify your frontend to present the approval URL
    // This could be via WebSocket, push notification, or polling
    notifyFrontend(cardAccountId, {
      approvalUrl: url,
      expiresAt: expiresAt,
      merchant: merchant,
      amount: amount,
      currency: currency
    });
  }

  res.status(200).json({ received: true });
});
Time Sensitivity: The approval URL expires after 5 minutes. Present it to the cardholder immediately upon receiving the webhook. If the URL expires, the transaction will fail and you’ll receive a challenge.failed event.

Step 2: Present Approval URL to Cardholder

Upon receiving the challenge.initiated event, you must present the approval URL to the cardholder. The URL must be opened in a secure context where the cardholder can review transaction details and make a decision.
Security Requirement: The approval or rejection must be performed by the cardholder on the page hosted at the provided URL. Your application must not attempt to replicate the approval UI or process the decision programmatically. The URL contains a one-time-use token and is the sole method for completing verification.

Mobile Application Integration

Recommended: Embedded Web View For the best user experience, load the approval URL within an embedded web view rather than redirecting to an external browser. This keeps users within your application context. iOS Example (SwiftUI):
import SwiftUI
import WebKit

struct ApprovalWebView: UIViewRepresentable {
    let url: URL
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        
        // Inject message handler for postMessage
        let contentController = WKUserContentController()
        contentController.add(context.coordinator, name: "transactionApproval")
        webView.configuration.userContentController = contentController
        
        return webView
    }
    
    func updateUIView(_ webView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        webView.load(request)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        func userContentController(_ userContentController: WKUserContentController, 
                                 didReceive message: WKScriptMessage) {
            if message.name == "transactionApproval" {
                if let data = message.body as? [String: Any],
                   let status = data["status"] as? String {
                    // Handle approval status
                    NotificationCenter.default.post(
                        name: NSNotification.Name("TransactionApprovalComplete"),
                        object: nil,
                        userInfo: ["status": status]
                    )
                }
            }
        }
    }
}
Android Example (Kotlin):
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient

class ApprovalWebViewClient(private val onComplete: (String) -> Unit) : WebViewClient() {
    
    override fun onPageFinished(view: WebView?, url: String?) {
        super.onPageFinished(view, url)
        
        // Inject JavaScript to listen for postMessage
        view?.evaluateJavascript("""
            window.addEventListener('message', function(event) {
                if (event.origin === 'https://approve.utgl.io') {
                    if (event.data && event.data.event === 'transactionApprovalComplete') {
                        AndroidBridge.onApprovalComplete(event.data.status);
                    }
                }
            });
        """, null)
    }
}

class ApprovalBridge(private val onComplete: (String) -> Unit) {
    @JavascriptInterface
    fun onApprovalComplete(status: String) {
        onComplete(status)
    }
}

// Usage
webView.webViewClient = ApprovalWebViewClient { status ->
    // Handle approval status
    when (status) {
        "succeeded" -> handleSuccess()
        "declined" -> handleDeclined()
        "failed" -> handleFailed()
    }
}

webView.addJavascriptInterface(
    ApprovalBridge { status -> /* handle */ },
    "AndroidBridge"
)
webView.loadUrl(approvalUrl)

Web Application Integration

For web applications, you can use an iframe or modal overlay:
// Create modal with iframe
function showApprovalModal(approvalUrl) {
  const modal = document.createElement('div');
  modal.className = 'approval-modal';
  
  const iframe = document.createElement('iframe');
  iframe.src = approvalUrl;
  iframe.style.width = '100%';
  iframe.style.height = '100%';
  iframe.style.border = 'none';
  
  modal.appendChild(iframe);
  document.body.appendChild(modal);
  
  // Listen for postMessage
  window.addEventListener('message', (event) => {
    // Security: verify origin
    if (event.origin !== 'https://approve.utgl.io') {
      return;
    }
    
    const eventData = event.data;
    if (eventData && eventData.event === 'transactionApprovalComplete') {
      handleApprovalComplete(eventData.status);
      document.body.removeChild(modal);
    }
  });
}

function handleApprovalComplete(status) {
  switch (status) {
    case 'succeeded':
      // Show success message
      break;
    case 'declined':
      // Show declined message
      break;
    case 'failed':
      // Show error message
      break;
  }
}

postMessage API

The approval page uses window.postMessage() to notify your application when the approval process completes. This allows automatic web view closure and seamless user experience. Message Structure:
{
  "event": "transactionApprovalComplete",
  "status": "succeeded"
}
Status Values:
  • succeeded - Cardholder approved the transaction
  • declined - Cardholder declined the transaction
  • failed - Challenge expired or system error occurred
JavaScript Listener:
window.addEventListener('message', (event) => {
  // Security best practice: always verify the origin
  if (event.origin !== 'https://approve.utgl.io') {
    return;
  }

  const eventData = event.data;

  if (eventData && eventData.event === 'transactionApprovalComplete') {
    const status = eventData.status;
    
    // Handle based on status
    switch (status) {
      case 'succeeded':
        handleSuccess();
        break;
      case 'declined':
        handleDeclined();
        break;
      case 'failed':
        handleFailed();
        break;
    }
    
    // Close web view (platform-specific)
    closeWebView();
  }
});

Step 3: Handle Challenge Resolution Events

After a challenge is initiated, the system will send exactly one terminal webhook event indicating the final outcome. Your system must handle all three possible outcomes.

Challenge Succeeded

Sent when the cardholder successfully approves the transaction. Event:
{
  "event": "cardaccount.transaction.challenge.succeeded",
  "id": "evt_2b3c4d5e6f7g8h9i0j1k",
  "time": "2025-09-12T11:51:00.000Z",
  "data": {
    "cardAccountId": "a857911b-d9c3-491a-98a7-a27c071d932d",
    "cardId": "438f4574-8d75-4938-8667-e626d181da19"
  }
}
Action: Proceed with transaction completion. The transaction has been verified and authorized.

Challenge Declined

Sent when the cardholder actively declines the transaction. Event:
{
  "event": "cardaccount.transaction.challenge.declined",
  "id": "evt_3c4d5e6f7g8h9i0j1k2l",
  "time": "2025-09-12T11:51:05.000Z",
  "data": {
    "cardAccountId": "a857911b-d9c3-491a-98a7-a27c071d932d",
    "cardId": "438f4574-8d75-4938-8667-e626d181da19"
  }
}
Action: Cancel the transaction. Inform the cardholder that the transaction was declined.

Challenge Failed

Sent when the challenge expires or fails due to a system error. Event:
{
  "event": "cardaccount.transaction.challenge.failed",
  "id": "evt_4d5e6f7g8h9i0j1k2l3m",
  "time": "2025-09-12T11:55:01.000Z",
  "data": {
    "cardAccountId": "a857911b-d9c3-491a-98a7-a27c071d932d",
    "cardId": "438f4574-8d75-4938-8667-e626d181da19",
    "reason": {
      "code": "expired",
      "message": "The approval challenge expired before a decision was made."
    }
  }
}
Failure Reasons:
CodeDescription
expiredApproval URL expired before cardholder completed verification
system_errorSystem error occurred during verification process
Action: Inform the cardholder and provide option to retry the transaction.

Complete Webhook Handler

app.post('/webhooks/utgl', async (req, res) => {
  // Verify signature
  if (!verifyWebhookSignature(req.body, req.headers['x-utgl-signature'])) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;
  const eventId = event.id;

  // Idempotency check
  if (await isEventProcessed(eventId)) {
    return res.status(200).json({ received: true });
  }

  switch (event.event) {
    case 'cardaccount.transaction.challenge.initiated':
      await handleChallengeInitiated(event);
      break;
      
    case 'cardaccount.transaction.challenge.succeeded':
      await handleChallengeSucceeded(event);
      break;
      
    case 'cardaccount.transaction.challenge.declined':
      await handleChallengeDeclined(event);
      break;
      
    case 'cardaccount.transaction.challenge.failed':
      await handleChallengeFailed(event);
      break;
  }

  // Mark event as processed
  await markEventProcessed(eventId);
  
  res.status(200).json({ received: true });
});

async function handleChallengeSucceeded(event) {
  const { cardAccountId, cardId } = event.data;
  
  // Update transaction status to verified
  await updateTransactionStatus(cardAccountId, 'verified');
  
  // Notify frontend
  notifyFrontend(cardAccountId, {
    status: 'succeeded',
    message: 'Transaction verified successfully'
  });
}

async function handleChallengeDeclined(event) {
  const { cardAccountId, cardId } = event.data;
  
  // Cancel transaction
  await cancelTransaction(cardAccountId);
  
  // Notify frontend
  notifyFrontend(cardAccountId, {
    status: 'declined',
    message: 'Transaction was declined by cardholder'
  });
}

async function handleChallengeFailed(event) {
  const { cardAccountId, cardId, reason } = event.data;
  
  // Handle failure
  await markTransactionFailed(cardAccountId, reason.code);
  
  // Notify frontend
  notifyFrontend(cardAccountId, {
    status: 'failed',
    reason: reason.code,
    message: reason.message,
    retryable: reason.code === 'expired'
  });
}

Best Practices

Security

  1. Verify Webhook Signatures - Always validate incoming webhook requests using the signature header
  2. HTTPS Only - Use HTTPS for all webhook endpoints
  3. Origin Verification - When listening for postMessage, always verify the origin is https://approve.utgl.io
  4. Time-Sensitive URLs - Present approval URLs immediately; they expire in 5 minutes

User Experience

  1. Embedded Web Views - Use embedded web views instead of external browser redirects
  2. Loading States - Show loading indicators while waiting for approval
  3. Error Handling - Provide clear error messages and retry options
  4. Automatic Closure - Use postMessage to automatically close web views after completion

Reliability

  1. Idempotency - Use event id field to prevent duplicate processing
  2. Event Logging - Log all webhook events for debugging and audit trails
  3. Retry Logic - Implement retry logic for failed webhook deliveries
  4. Status Tracking - Track challenge state to handle edge cases

Performance

  1. Immediate Presentation - Present approval URL as soon as webhook is received
  2. Async Processing - Process webhooks asynchronously to respond quickly
  3. Connection Pooling - Reuse HTTP connections for webhook processing

Challenge Lifecycle States

Understanding the challenge lifecycle helps you handle edge cases: State Transitions:
  • InitiatedSucceeded: Cardholder approves within timeout window
  • InitiatedDeclined: Cardholder actively declines
  • InitiatedFailed: 5-minute timeout expires or system error occurs
One Terminal Event: Each challenge will result in exactly one terminal event (succeeded, declined, or failed). Your system should handle all three outcomes.

Troubleshooting

Common Issues

IssuePossible CauseSolution
No challenge.initiated webhook receivedWebhook endpoint not configured or not accessibleVerify webhook endpoint in dashboard and check network connectivity
Approval URL expiredUser took longer than 5 minutesPresent URL immediately upon receipt. If expired, prompt user to retry transaction
challenge.failed with expired code5-minute timeout elapsedInform user and provide option to retry the transaction
postMessage not receivedOrigin mismatch or JavaScript errorEnsure listening for messages from https://approve.utgl.io origin
Duplicate webhook eventsNetwork retry or webhook replayUse event id field for idempotency checks
Webhook signature verification failsInvalid signature or key mismatchVerify webhook secret key and signature algorithm

Debugging Tips

  1. Log All Events - Log incoming webhook events with timestamps and event IDs
  2. Track Challenge State - Maintain state mapping between cardAccountId and challenge status
  3. Monitor Expiration - Track expiresAt timestamps to identify timing issues
  4. Test Webhook Delivery - Use webhook testing tools to verify endpoint accessibility

Testing

Test Mode

In sandbox/test mode, you can test the OOB flow:
  1. Initiate Test Transaction - Create a transaction that triggers OOB verification
  2. Receive Webhook - Verify challenge.initiated webhook is received
  3. Present URL - Load approval URL in test environment
  4. Complete Challenge - Approve or decline to test different outcomes
  5. Verify Terminal Event - Confirm terminal webhook is received

Test Scenarios

  • Successful Approval - Complete approval flow and verify challenge.succeeded event
  • User Decline - Decline transaction and verify challenge.declined event
  • Timeout - Wait for expiration and verify challenge.failed with expired code
  • Network Issues - Test behavior when webhook delivery fails

Next Steps