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:
| Status | Meaning |
|---|---|
APPROVED | Transfer completed successfully |
DECLINED | Transfer rejected or failed |
LAPSED | Transfer not confirmed within the deadline |
Callbacks are NOT sent for intermediate states i.e.
INITIATEDandPROCESSING.
What Your Callback Endpoint Must Do
Protocol Requirements
Your callback endpoint must:
- Accept
POSTrequests with JSON payloads - Accept
application/jsonpayloads - 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 stateupdated_timestamp: Final state timestampdecline_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:
- Initial attempt: Immediately (~1 second after transaction completes)
- Immediate retry: Right away (~1 second after initial attempt fails)
- 1st scheduled retry: 5 minutes after immediate retry fails
- 2nd scheduled retry: 15 minutes after 1st scheduled retry fails
- 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
- Callback Success Rate: Percentage of callbacks acknowledged with 2xx
- Alert when callback success rate drops below 95%
- Processing Time: Time to acknowledge callback
- Alert when processing time exceeds 5 seconds
- Duplicate Rate: Frequency of duplicate callbacks
- 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.