Implementing Callback for Unified Transfer

⚠️

Unified Transfer is in active development. As a pioneer merchant, you will be informed of the updates through your Maya Relationship Manager.

Overview

Unified Transfer uses asynchronous callbacks to deliver final transfer results. When a transfer completes processing, Maya sends an HTTP POST request to your registered callback endpoint with the transaction result. Callbacks are critical for receiving asynchronous transaction status updates.

Callbacks are critical for receiving asynchronous transaction status updates.

This guide provides comprehensive guidance on the protocol behavior and implementation advice in building your callback endpoint.

Why Callbacks Matter:

  • Provide real-time status updates without polling
  • Enable immediate response to transaction outcomes
  • Support high-volume operations efficiently
  • Ensure reliable delivery with automatic retries

When Callbacks Are Sent

A callback is triggered when a transfer transitions to a final status:

StatusMeaning
APPROVEDTransfer completed successfully
DECLINEDTransfer rejected or failed
LAPSEDTransfer not confirmed within the deadline

Callbacks are NOT sent for intermediate states i.e. INITIATED and PROCESSING.


What Your Callback Endpoint Must Do

Protocol Requirements

Your callback endpoint must:

  • Accept POST requests with JSON payloads
  • Accept application/json payloads
  • Be publicly accessible via HTTPS
  • Return a 2xx status code (200-299) to acknowledge successful receipt
  • Respond quickly (≤ 5 seconds recommended)
  • Handle high availability (99.9%+ uptime recommended)

If your endpoint does not return a 2xx response, Maya treats the delivery as failed and retries.

Implementation Requirements

Your callback handler must:

  • Process idempotently - Handle duplicate notifications gracefully.
  • Log all callbacks - Maintain audit trail for troubleshooting.
  • Handle errors gracefully - Don't fail on unexpected fields
  • Process asynchronously - Acknowledge receipt immediately, process in the background

Callback Payload Structure

Standard Callback Notification Format

Maya sends transaction status updates in this format (for APPROVED or LAPSED events):

{
  "data": {
    "id": "3ebc4615-d8a1-468b-b72c-fb71ff6c5d03",
    "status": "APPROVED",
    "created_timestamp": "2025-01-08 09:22:12.212",
    "updated_timestamp": "2025-01-08 09:25:30.445",
    "confirmation_deadline": "2025-01-08 10:22:12.212",
    "initiation": {
      "debit_account": {
        "financial_institution_code": "PAPHPHM1XXX",
        "account_number": "041279562523"
      },
      "credit_account": {
        "financial_institution_code": "MBTCPHMMXXX",
        "account_number": "772356410242",
        "account_name": "Maria Reyes"
      },
      "amount": {
        "currency": "PHP",
        "value": 1000.00
      }
    },
    "transfer_details": {
      "gross_amount": {
        "currency": "PHP",
        "value": 1007.00
      },
      "principal_amount": {
        "currency": "PHP",
        "value": 1000.00
      },
      "fee": {
        "currency": "PHP",
        "value": 7.00
      },
      "debit_account": {
        "ending_balance": {
          "available_balance": {
            "currency": "PHP",
            "value": 5000.00
          },
          "current_balance": {
            "currency": "PHP",
            "value": 5000.00
          }
        }
      }
    }
  }
}

Callback Notification Format for Declined Transfers

When a transfer is DECLINED, the callback includes a decline_reason:

{
  "data": {
    "id": "c36d9958-9c55-49e3-b70e-702b082046c0",
    "status": "DECLINED",
    "decline_reason": {
      "code": "TRGCWDLC01",
      "description": "The credit account does not exist in the specified financial institution. You may ask the customer to provide a valid credit account."
    },
    "created_timestamp": "2025-01-08 09:22:12.212",
    "updated_timestamp": "2025-01-08 09:25:30.445",
    "confirmation_deadline": "2025-01-08 10:22:12.212",
    "initiation": { /* transfer details */ },
    "transfer_details": {
      "gross_amount": {
        "currency": "PHP",
        "value": 1007.00
      },
      "principal_amount": {
        "currency": "PHP",
        "value": 1000.00
      },
      "fee": {
        "currency": "PHP",
        "value": 7.00
      }
    }
  }
}

Field Notes

  • id: Unified Transfer ID (primary reference key)
  • status: Final state
  • updated_timestamp: Final state timestamp
  • decline_reason: Present only for DECLINED

Treat unknown fields as forward-compatible. Do not hard-fail on new properties.


Acknowledgement Model

Your callback endpoint should:

  • Immediately return 200 OK
  • Process business logic asynchronously

Your callback endpoint should NOT:

  • Perform long-running operations before responding
  • Return 4xx/5xx due to business validation issues
  • Block while calling downstream services

Callback Retry Strategy

Maya implements an automatic retry mechanism to ensure reliable callback delivery.

Retry Schedule

Maya will attempt delivery up to 5 times total:

  1. Initial attempt: Immediately (~1 second after transaction completes)
  2. Immediate retry: Right away (~1 second after initial attempt fails)
  3. 1st scheduled retry: 5 minutes after immediate retry fails
  4. 2nd scheduled retry: 15 minutes after 1st scheduled retry fails
  5. 3rd scheduled retry: 45 minutes after 2nd scheduled retry fails

After final retry fails, no further automatic delivery occurs.

Retry Triggers

Retries are triggered when:

  • Your server responds with 3xx, 4xx, or 5xx status codes
  • Request times out (no response within timeout period)
  • Network connectivity issues prevent delivery

Idempotency Implementation (Mandatory)

Why Idempotency Matters

Due to the retry mechanism, your endpoint may receive the same callback multiple times. Implementing idempotency ensures:

  • Duplicate callbacks don't cause duplicate processing
  • Database consistency is maintained
  • User notifications aren't sent multiple times

Idempotency Strategies

1. Database Flag Approach

Store processed callback IDs, then reject reprocessing if already handled.

async function checkIfAlreadyProcessed(transferId) {
  const result = await db.query(
    'SELECT processed FROM callbacks WHERE transfer_id = ?',
    [transferId]
  );
  return result.length > 0 && result[0].processed;
}

async function markAsProcessed(transferId) {
  await db.query(
    'INSERT INTO callbacks (transfer_id, processed, processed_at) VALUES (?, true, NOW()) ON DUPLICATE KEY UPDATE processed = true, processed_at = NOW()',
    [transferId]
  );
}

2. Transaction Status Approach

Only process if the incoming status differs from your stored state.

async function processCallback(transferData) {
  // Check current status in database
  const currentStatus = await getTransferStatus(transferData.id);
  
  // Only process if status is changing
  if (currentStatus === transferData.status) {
    console.log(`Transfer ${transferData.id} already in ${currentStatus} status`);
    return;
  }
  
  // Update status and process
  await updateTransferStatus(transferData.id, transferData.status);
  await performBusinessLogic(transferData);
}

Implementation Examples

Node.js/Express Example

const express = require('express');
const app = express();

app.use(express.json());

// Callback endpoint
app.post('/callbacks/unified-transfers', async (req, res) => {
  try {
    // 1. Acknowledge receipt immediately
    res.status(200).json({ received: true });
  
    // 2. Process asynchronously
    const transferData = req.body.data;
  
    // 3. Check for duplicate (idempotency)
    const isDuplicate = await checkIfAlreadyProcessed(transferData.id);
    if (isDuplicate) {
      console.log(`Duplicate callback for transfer ${transferData.id}`);
      return;
    }
  
    // 4. Process based on status
    switch (transferData.status) {
      case 'APPROVED':
        await handleApprovedTransfer(transferData);
        break;
      case 'DECLINED':
        await handleDeclinedTransfer(transferData);
        break;
      case 'LAPSED':
        await handleLapsedTransfer(transferData);
        break;
      default:
        console.log(`Unknown status: ${transferData.status}`);
    }
  
    // 5. Mark as processed
    await markAsProcessed(transferData.id);
  
  } catch (error) {
    // Log error but don't fail the response
    console.error('Error processing callback:', error);
  }
});

async function handleApprovedTransfer(transferData) {
  console.log(`Transfer ${transferData.id} approved`);
  // Update database
  // Notify user
  // Update accounting system
}

async function handleDeclinedTransfer(transferData) {
  console.log(`Transfer ${transferData.id} declined`);
  const reason = transferData.decline_reason?.description;
  // Update database
  // Notify user with reason
  // Handle refund if needed
}

async function handleLapsedTransfer(transferData) {
  console.log(`Transfer ${transferData.id} lapsed`);
  // Update database
  // Notify user
}

app.listen(3000, () => {
  console.log('Callback server running on port 3000');
});

Python/Flask Example

from flask import Flask, request, jsonify
import logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

@app.route('/callbacks/unified-transfers', methods=['POST'])
def handle_callback():
    try:
        # 1. Acknowledge receipt immediately
        transfer_data = request.json.get('data', {})
    
        # 2. Check for duplicate (idempotency)
        if is_already_processed(transfer_data['id']):
            logging.info(f"Duplicate callback for transfer {transfer_data['id']}")
            return jsonify({'received': True}), 200
    
        # 3. Process based on status
        status = transfer_data['status']
    
        if status == 'APPROVED':
            handle_approved_transfer(transfer_data)
        elif status == 'DECLINED':
            handle_declined_transfer(transfer_data)
        elif status == 'LAPSED':
            handle_lapsed_transfer(transfer_data)
        else:
            logging.warning(f"Unknown status: {status}")
    
        # 4. Mark as processed
        mark_as_processed(transfer_data['id'])
    
        return jsonify({'received': True}), 200
    
    except Exception as e:
        # Log error but don't fail the response
        logging.error(f"Error processing callback: {e}")
        return jsonify({'received': True}), 200

def handle_approved_transfer(transfer_data):
    logging.info(f"Transfer {transfer_data['id']} approved")
    # Update database
    # Notify user
    # Update accounting system

def handle_declined_transfer(transfer_data):
    logging.info(f"Transfer {transfer_data['id']} declined")
    reason = transfer_data.get('decline_reason', {}).get('description')
    # Update database
    # Notify user with reason
    # Handle refund if needed

def handle_lapsed_transfer(transfer_data):
    logging.info(f"Transfer {transfer_data['id']} lapsed")
    # Update database
    # Notify user

if __name__ == '__main__':
    app.run(port=3000)

Fallback Polling Strategy

If all callback attempts fail, implement a fallback polling mechanism.

Polling requires:

  • Bearer token
  • JWS signature (empty payload)

Polling should be:

  • Rate limited
  • Used only for transfers without a final status after a reasonable time. Callbacks remain the primary mechanism.
// Poll for transfers that haven't received callbacks
async function pollPendingTransfers() {
  const pendingTransfers = await db.query(
    'SELECT * FROM transfers WHERE status = ? AND created_at < NOW() - INTERVAL 10 MINUTE',
    ['PROCESSING']
  );
  
  for (const transfer of pendingTransfers) {
    try {
      // Query Maya API for current status
      const response = await fetch(
        `https://api.maya.ph/v1/transfers/p2p/${transfer.id}`,
        {
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'x-jws-signature': createSignature({})
          }
        }
      );
  
      const data = await response.json();
  
      // Process if status has changed
      if (data.data.status !== transfer.status) {
        await processCallback(data.data);
      }
    } catch (error) {
      console.error(`Error polling transfer ${transfer.id}:`, error);
    }
  }
}

// Run every 5 minutes
setInterval(pollPendingTransfers, 5 * 60 * 1000);

Security Considerations for Callbacks

Validate Callback Source

While Maya doesn't currently provide signature verification, you can implement additional security:

1. IP Whitelisting

  • Restrict callback endpoint to Maya's IP addresses
  • Configure firewall rules

2. Shared Secret

  • Include a shared secret in callback URL query parameter
  • Validate the secret before processing

3. HTTPS Only

  • Always use HTTPS for callback endpoints
  • Validate SSL certificates

Protect Sensitive Data

  • Don't log full account numbers or sensitive PII
  • Mask sensitive data in logs
  • Implement proper access controls

Monitoring and Alerting

Key Metrics to Monitor and Recommended Alerts

  1. Callback Success Rate: Percentage of callbacks acknowledged with 2xx
    • Alert when callback success rate drops below 95%
  2. Processing Time: Time to acknowledge callback
    • Alert when processing time exceeds 5 seconds
  3. Duplicate Rate: Frequency of duplicate callbacks
  4. Error Rate: Failed callback processing attempts
    • Alert when error rate exceeds 1%
    • Alert when callbacks stop arriving for active transfers

Logging Best Practices

// Log callback receipt
logger.info('Callback received', {
  transferId: transferData.id,
  status: transferData.status,
  timestamp: new Date().toISOString()
});

// Log processing result
logger.info('Callback processed', {
  transferId: transferData.id,
  status: transferData.status,
  processingTime: processingTimeMs,
  isDuplicate: isDuplicate
});

// Log errors
logger.error('Callback processing failed', {
  transferId: transferData.id,
  error: error.message,
  stack: error.stack
});

Testing Your Callback Endpoint

Local Testing with ngrok

# Start ngrok tunnel
ngrok http 3000

# Use the ngrok URL as your callback endpoint
# Example: https://abc123.ngrok.io/callbacks/unified-transfers

Testing Checklist

  • Endpoint returns 2xx for valid callbacks
  • Handles duplicate callbacks idempotently
  • Processes APPROVED status correctly
  • Processes DECLINED status correctly
  • Processes LAPSED status correctly
  • Logs all callback attempts
  • Responds within 5 seconds
  • Handles malformed payloads gracefully
  • Implements fallback polling for missed callbacks

Troubleshooting

Issue: Callbacks not being received

  • Verify endpoint is publicly accessible
  • Check firewall rules
  • Confirm HTTPS is properly configured
  • Verify the URL provided to Maya is correct

Issue: Duplicate callbacks

  • Implement idempotency checks
  • Verify a 2xx status code is returned
  • Check response time (should be < 5 seconds)

Issue: Callbacks timing out

  • Move processing to a background job
  • Return 2xx immediately
  • Optimize database queries
  • Scale infrastructure if needed

Next Steps

Now that you have implemented and tested your callback endpoint, provide it to Maya (through your Maya Relationship Manager) for registration.