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
- Transaction Initiation - Cardholder initiates an online payment
- OOB Detection - UTGL system determines if OOB verification is required
- Challenge Initiated - Approval URL generated and sent via webhook
- User Verification - Cardholder reviews and approves/declines on secure page
- Completion - Terminal webhook sent with final outcome
Webhook Events
The OOB integration uses four webhook events to manage the complete transaction lifecycle:
| Event | When Sent | Purpose |
|---|
cardaccount.transaction.challenge.initiated | When OOB verification is required | Contains approval URL and transaction details |
cardaccount.transaction.challenge.succeeded | Cardholder approves transaction | Confirms successful verification |
cardaccount.transaction.challenge.declined | Cardholder declines transaction | Indicates active rejection |
cardaccount.transaction.challenge.failed | Challenge expires or system error | Indicates failure or timeout |
Integration Guide
Prerequisites
Before integrating OOB, ensure you have:
- Webhook endpoint configured - Secure HTTPS endpoint to receive events
- Webhook signature verification - Validate incoming webhook requests
- Event processing system - Handle webhook events reliably
- 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
| Field | Type | Description |
|---|
url | string | Unique, time-sensitive URL for cardholder approval. Must be presented to cardholder immediately. |
expiresAt | string (ISO 8601) | Timestamp when the approval URL expires (typically 5 minutes from generation) |
cardAccountId | string (UUID) | ID of the card account associated with the transaction |
cardId | string (UUID) | ID of the card used for the transaction |
merchant | string | Merchant name for display to cardholder |
currency | string | Transaction currency (ISO 4217 code) |
amount | number | Transaction 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:
| Code | Description |
|---|
expired | Approval URL expired before cardholder completed verification |
system_error | System 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
- Verify Webhook Signatures - Always validate incoming webhook requests using the signature header
- HTTPS Only - Use HTTPS for all webhook endpoints
- Origin Verification - When listening for
postMessage, always verify the origin is https://approve.utgl.io
- Time-Sensitive URLs - Present approval URLs immediately; they expire in 5 minutes
User Experience
- Embedded Web Views - Use embedded web views instead of external browser redirects
- Loading States - Show loading indicators while waiting for approval
- Error Handling - Provide clear error messages and retry options
- Automatic Closure - Use
postMessage to automatically close web views after completion
Reliability
- Idempotency - Use event
id field to prevent duplicate processing
- Event Logging - Log all webhook events for debugging and audit trails
- Retry Logic - Implement retry logic for failed webhook deliveries
- Status Tracking - Track challenge state to handle edge cases
- Immediate Presentation - Present approval URL as soon as webhook is received
- Async Processing - Process webhooks asynchronously to respond quickly
- Connection Pooling - Reuse HTTP connections for webhook processing
Challenge Lifecycle States
Understanding the challenge lifecycle helps you handle edge cases:
State Transitions:
- Initiated → Succeeded: Cardholder approves within timeout window
- Initiated → Declined: Cardholder actively declines
- Initiated → Failed: 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
| Issue | Possible Cause | Solution |
|---|
No challenge.initiated webhook received | Webhook endpoint not configured or not accessible | Verify webhook endpoint in dashboard and check network connectivity |
| Approval URL expired | User took longer than 5 minutes | Present URL immediately upon receipt. If expired, prompt user to retry transaction |
challenge.failed with expired code | 5-minute timeout elapsed | Inform user and provide option to retry the transaction |
postMessage not received | Origin mismatch or JavaScript error | Ensure listening for messages from https://approve.utgl.io origin |
| Duplicate webhook events | Network retry or webhook replay | Use event id field for idempotency checks |
| Webhook signature verification fails | Invalid signature or key mismatch | Verify webhook secret key and signature algorithm |
Debugging Tips
- Log All Events - Log incoming webhook events with timestamps and event IDs
- Track Challenge State - Maintain state mapping between
cardAccountId and challenge status
- Monitor Expiration - Track
expiresAt timestamps to identify timing issues
- Test Webhook Delivery - Use webhook testing tools to verify endpoint accessibility
Testing
Test Mode
In sandbox/test mode, you can test the OOB flow:
- Initiate Test Transaction - Create a transaction that triggers OOB verification
- Receive Webhook - Verify
challenge.initiated webhook is received
- Present URL - Load approval URL in test environment
- Complete Challenge - Approve or decline to test different outcomes
- 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