The Bank Transfers API lets you send funds from your Tsara virtual bank account to other Tsara accounts or external bank accounts.
It's ideal for automating payouts, vendor payments, settlements, or user withdrawals β all while maintaining compliance and audit trails.
π Overview
What You Can Do
- Transfer funds between Tsara accounts (internal transfer - FREE)
- Send payouts to external bank accounts (NGN with fees)
- View transfer status and history with pagination
- Receive webhook notifications for completed or failed transfers
- Validate bank account details before sending funds
Transfer Types
| Type | Description | Speed | Fee | Use Case |
|---|---|---|---|---|
| Internal | Tsara account to Tsara account | Instant | FREE | Business-to-business settlements, affiliate payouts |
| External | Tsara account to external bank | 5-30 minutes | 2% (instant) or 1.5% (T+1) | Customer withdrawals, vendor payments |
Key Features
- Account Validation: Verify account names before transfers using name enquiry
- Idempotent References: Prevent duplicate transfers with unique reference IDs
- Real-time Status: Track transfers through processing β success/failed lifecycle
- Webhook Notifications: Receive instant updates on transfer completion
- Audit Trail: Full transfer history with pagination and filtering
π§Ύ Create a Bank Transfer
Endpoint
POST /fiat/transfers
Authentication
Requires Secret Key in Authorization header (server-side only).
Description
Initiates a transfer from your Tsara virtual bank account to another Tsara account or external bank account. Transfers are processed asynchronously and you'll receive webhook notifications when they complete.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
source_bank_account_id | string | Required | Your Tsara virtual bank account ID (e.g., bank_123) |
destination | object | Required | Destination bank account details |
destination.type | string | Required | Must be bank_account |
destination.bank_code | string | Required | Nigerian bank code (e.g., 035 for Wema Bank). Get from /fiat/transfers/banks |
destination.account_number | string | Required | 10-digit NUBAN account number |
destination.account_name | string | Required | Account holder name (must match bank records) |
amount | integer | Required | Amount in kobo (25000 = β¦250.00) |
currency | string | Required | Must be NGN |
reference | string | Required | Unique reference for idempotency (max 100 chars) |
narration | string | Optional | Transfer description shown on bank statement (max 100 chars) |
settlement_type | string | Optional | instant (2% fee) or t1 (1.5% fee, next business day). Defaults to instant |
Request Body Example
{
"source_bank_account_id": "bank_123",
"destination": {
"type": "bank_account",
"bank_code": "035",
"account_number": "1234567890",
"account_name": "John Doe"
},
"amount": 25000,
"currency": "NGN",
"reference": "payout_001",
"narration": "Vendor payment - INV #1001",
"settlement_type": "instant"
}Example Request
curl -X POST "https://sandbox.tsara.ng/v1/fiat/transfers" \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_bank_account_id": "bank_123",
"destination": {
"type": "bank_account",
"bank_code": "035",
"account_number": "1234567890",
"account_name": "John Doe"
},
"amount": 25000,
"currency": "NGN",
"reference": "payout_001",
"narration": "Vendor payment",
"settlement_type": "instant"
}'Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether request was successful |
data | object | Transfer details |
data.id | string | Unique transfer ID (e.g., trf_123) |
data.source_bank_account_id | string | Source account ID |
data.amount | integer | Amount transferred in kobo |
data.currency | string | Currency code (NGN) |
data.destination | object | Destination account details |
data.destination.bank_name | string | Bank name (e.g., Wema Bank) |
data.destination.account_number | string | Account number |
data.destination.account_name | string | Account holder name |
data.status | string | Transfer status: processing, success, or failed |
data.reference | string | Your unique reference |
data.narration | string | Transfer description |
data.fee | integer | Transfer fee charged in kobo |
data.settlement_type | string | Settlement speed chosen |
data.created_at | string | ISO 8601 timestamp |
data.completed_at | string | ISO 8601 timestamp (null if still processing) |
request_id | string | Unique request identifier for debugging |
Response Example
{
"success": true,
"data": {
"id": "trf_123",
"source_bank_account_id": "bank_123",
"amount": 25000,
"currency": "NGN",
"destination": {
"bank_name": "Wema Bank",
"account_number": "1234567890",
"account_name": "John Doe"
},
"status": "processing",
"reference": "payout_001",
"narration": "Vendor payment",
"fee": 500,
"settlement_type": "instant",
"created_at": "2025-10-17T12:00:00Z",
"completed_at": null
},
"request_id": "req_123"
}Transfer Status Lifecycle
βββββββββββββββ
β processing β Initial state when transfer is created
ββββββββ¬βββββββ
β
βββββββΊ success (Funds delivered to destination)
β
βββββββΊ failed (Transfer rejected or unsuccessful)
Status Descriptions
| Status | Description | Next Actions |
|---|---|---|
processing | Transfer submitted to bank network | Wait for webhook or poll status |
success | Funds successfully delivered | Update user balance, send confirmation |
failed | Transfer failed (invalid account, insufficient funds, etc.) | Check failure reason, retry if appropriate |
π Retrieve a Bank Transfer
Endpoint
GET /fiat/transfers?id={transfer_id}
Authentication
Requires Secret Key in Authorization header.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Required | Transfer ID to retrieve (e.g., trf_123) |
Example Request
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers?id=trf_123" \
-H "Authorization: Bearer YOUR_SECRET_KEY"Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether request was successful |
data | object | Transfer details (same structure as create response) |
data.id | string | Transfer ID |
data.amount | integer | Amount in kobo |
data.currency | string | Currency code |
data.destination | object | Destination bank details |
data.status | string | Current transfer status |
data.reference | string | Your unique reference |
data.failure_reason | string | Reason for failure (only present if status is failed) |
data.created_at | string | Creation timestamp |
data.completed_at | string | Completion timestamp |
Response Example
{
"success": true,
"data": {
"id": "trf_123",
"amount": 25000,
"currency": "NGN",
"destination": {
"bank_name": "Wema Bank",
"account_number": "1234567890",
"account_name": "John Doe"
},
"status": "success",
"reference": "payout_001",
"narration": "Vendor payment",
"fee": 500,
"created_at": "2025-10-17T12:00:00Z",
"completed_at": "2025-10-17T12:05:23Z"
}
}π List Bank Transfers
Endpoint
GET /fiat/transfers
Authentication
Requires Secret Key in Authorization header.
Description
Retrieve paginated list of all your bank transfers with optional filtering.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | Page number (default: 1) |
limit | integer | Optional | Results per page (default: 20, max: 100) |
status | string | Optional | Filter by status: processing, success, or failed |
reference | string | Optional | Filter by your reference ID |
start_date | string | Optional | Filter transfers from this date (ISO 8601) |
end_date | string | Optional | Filter transfers until this date (ISO 8601) |
Example Request
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers?page=1&limit=20&status=success" \
-H "Authorization: Bearer YOUR_SECRET_KEY"Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether request was successful |
data | array | Array of transfer objects |
data[].id | string | Transfer ID |
data[].amount | integer | Amount in kobo |
data[].status | string | Transfer status |
data[].destination | object | Destination details |
data[].reference | string | Your reference |
data[].created_at | string | Creation timestamp |
pagination | object | Pagination details |
pagination.page | integer | Current page number |
pagination.limit | integer | Results per page |
pagination.total | integer | Total number of transfers |
pagination.total_pages | integer | Total number of pages |
Response Example
{
"success": true,
"data": [
{
"id": "trf_001",
"amount": 15000,
"currency": "NGN",
"status": "success",
"destination": {
"bank_name": "Access Bank",
"account_number": "0112233445",
"account_name": "Jane Smith"
},
"reference": "payout_100",
"fee": 300,
"created_at": "2025-10-17T10:00:00Z",
"completed_at": "2025-10-17T10:08:15Z"
},
{
"id": "trf_002",
"amount": 20000,
"currency": "NGN",
"status": "failed",
"destination": {
"bank_name": "Zenith Bank",
"account_number": "2009988776",
"account_name": "Invalid Account"
},
"reference": "payout_101",
"failure_reason": "Invalid account number",
"created_at": "2025-10-17T11:00:00Z",
"completed_at": "2025-10-17T11:02:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"total_pages": 1
}
}π¦ Get Bank List
Endpoint
GET /fiat/transfers/banks
Authentication
Requires Secret Key in Authorization header.
Description
Retrieve list of all supported Nigerian banks with their bank codes. Use these codes when creating transfers.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | Page number (default: 1) |
limit | integer | Optional | Results per page (default: 100) |
Example Request
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers/banks" \
-H "Authorization: Bearer YOUR_SECRET_KEY"Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether request was successful |
data | array | Array of bank objects |
data[].name | string | Official bank name |
data[].bankCode | string | Bank code to use in transfers |
data[].alias | array | Alternative names for the bank |
data[].routingKey | string | CBN routing key |
data[].nubanCode | string | NUBAN code (may be null) |
data[].categoryId | string | Bank category identifier |
data[].logoImage | string | URL to bank logo (may be null) |
pagination | object | Pagination details |
Response Example
{
"success": true,
"data": [
{
"name": "WEMA BANK",
"alias": ["WEMA BANK", "WEMA"],
"routingKey": "000017",
"logoImage": null,
"bankCode": "035",
"categoryId": "1",
"nubanCode": "035"
},
{
"name": "ACCESS BANK",
"alias": ["ACCESS BANK", "ACCESS"],
"routingKey": "000014",
"logoImage": null,
"bankCode": "044",
"categoryId": "1",
"nubanCode": "044"
},
{
"name": "SIGNATURE BANK",
"alias": ["SIGNATURE BANK"],
"routingKey": "000034",
"logoImage": null,
"bankCode": "000034",
"categoryId": "2",
"nubanCode": null
}
],
"pagination": {
"page": 1,
"limit": 100,
"total": 24
}
}π Bank Account Name Enquiry
Endpoint
POST /fiat/transfers/name-enquiry
Authentication
Requires Secret Key in Authorization header.
Description
Validate a bank account number and retrieve the account holder's name before initiating a transfer. This helps prevent sending funds to wrong accounts.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
bank_code | string | Required | Bank code (e.g., 035 for Wema Bank) |
account_number | string | Required | 10-digit NUBAN account number |
Request Body Example
{
"bank_code": "100004",
"account_number": "8093930950"
}Example Request
curl -X POST "https://sandbox.tsara.ng/v1/fiat/transfers/name-enquiry" \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"bank_code": "100004",
"account_number": "8093930950"
}'Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether request was successful |
status | string | Response status (e.g., success) |
status_code | integer | HTTP status code |
message | string | Human-readable message |
data | object | Account details |
data.bank_code | string | Bank code queried |
data.account_number | string | Account number queried |
data.account_name | string | Account holder's name from bank records |
Response Example
{
"success": true,
"status": "success",
"status_code": 200,
"message": "Account Name Retrieved",
"data": {
"bank_code": "100004",
"account_number": "8093930950",
"account_name": "JOHN VICTOR DOE"
}
}Failed Validation Example
{
"success": false,
"status": "error",
"status_code": 400,
"message": "Could not verify account",
"data": {
"bank_code": "100004",
"account_number": "0000000000",
"account_name": null
}
}π Webhook Events
When a transfer completes or fails, Tsara will notify your configured webhook endpoint.
Event Types
| Event Type | Description | When Triggered |
|---|---|---|
fiat.sent | Transfer successfully completed | Funds delivered to destination account |
fiat.failed | Transfer failed | Invalid account, insufficient funds, or bank rejection |
Webhook Payload Structure
| Field | Type | Description |
|---|---|---|
id | string | Unique webhook event ID |
type | string | Event type (fiat.sent or fiat.failed) |
data | object | Transfer details |
data.transfer_id | string | Transfer ID |
data.amount | integer | Amount in kobo |
data.currency | string | Currency code |
data.reference | string | Your unique reference |
data.status | string | Transfer status (success or failed) |
data.destination | object | Destination bank details |
data.failure_reason | string | Reason for failure (only in fiat.failed events) |
data.created_at | string | Transfer creation timestamp |
data.completed_at | string | Transfer completion timestamp |
Success Event Example
{
"id": "evt_901",
"type": "fiat.sent",
"data": {
"transfer_id": "trf_123",
"amount": 25000,
"currency": "NGN",
"reference": "payout_001",
"status": "success",
"destination": {
"bank_name": "Wema Bank",
"account_number": "1234567890",
"account_name": "John Doe"
},
"created_at": "2025-10-17T12:00:00Z",
"completed_at": "2025-10-17T12:05:00Z"
}
}Failed Event Example
{
"id": "evt_902",
"type": "fiat.failed",
"data": {
"transfer_id": "trf_124",
"amount": 15000,
"currency": "NGN",
"reference": "payout_002",
"status": "failed",
"failure_reason": "Insufficient balance in source account",
"destination": {
"bank_name": "Access Bank",
"account_number": "0112233445",
"account_name": "Jane Smith"
},
"created_at": "2025-10-17T13:00:00Z",
"completed_at": "2025-10-17T13:01:30Z"
}
}π° Fee Structure
| Settlement Type | Fee | Processing Time | Best For |
|---|---|---|---|
| Instant | 2% | 5-30 minutes | Urgent payouts, customer withdrawals |
| T+1 | 1.5% | Next business day | Bulk vendor payments, scheduled payouts |
| Internal | FREE | Instant | Tsara-to-Tsara transfers |
Fee Calculation Examples
Instant Transfer (2%)
Transfer Amount: β¦10,000.00 (1,000,000 kobo)
Fee: β¦200.00 (20,000 kobo)
Total Deducted: β¦10,200.00
Recipient Receives: β¦10,000.00
T+1 Transfer (1.5%)
Transfer Amount: β¦10,000.00 (1,000,000 kobo)
Fee: β¦150.00 (15,000 kobo)
Total Deducted: β¦10,150.00
Recipient Receives: β¦10,000.00
Internal Transfer (FREE)
Transfer Amount: β¦10,000.00 (1,000,000 kobo)
Fee: β¦0.00
Total Deducted: β¦10,000.00
Recipient Receives: β¦10,000.00
β οΈ Error Responses
Common Errors
| Error Code | Message | Cause | Solution |
|---|---|---|---|
400 | Invalid bank code | Bank code doesn't exist | Use /fiat/transfers/banks to get valid codes |
400 | Invalid account number | Account number format is wrong | Ensure 10-digit NUBAN format |
400 | Account name mismatch | Provided name doesn't match bank records | Use name enquiry endpoint first |
400 | Insufficient balance | Source account has insufficient funds | Check account balance before transfer |
400 | Duplicate reference | Reference already used | Use unique reference for each transfer |
401 | Unauthorized | Invalid or missing API key | Check Authorization header with Secret Key |
404 | Transfer not found | Transfer ID doesn't exist | Verify transfer ID is correct |
422 | Invalid amount | Amount is zero, negative, or exceeds limit | Check amount is positive and within limits |
500 | Transfer processing failed | Bank network error | Retry after a few minutes |
Error Response Structure
{
"success": false,
"status": "error",
"status_code": 400,
"message": "Invalid bank code",
"errors": {
"bank_code": [
"The provided bank code does not exist"
]
}
}π‘ Use Cases
1. Customer Withdrawal System
Allow users to withdraw their balance to their bank accounts.
Workflow:
- User requests withdrawal in your app
- Validate user has sufficient balance
- Show name enquiry to confirm account details
- User confirms withdrawal
- Create transfer with unique reference (user_id + timestamp)
- Update user balance to "pending withdrawal"
- Listen for webhook to confirm completion
- Update user balance and send confirmation
Example:
{
"source_bank_account_id": "bank_platform_main",
"destination": {
"type": "bank_account",
"bank_code": "035",
"account_number": "1234567890",
"account_name": "JOHN DOE"
},
"amount": 5000000,
"currency": "NGN",
"reference": "withdrawal_user_12345_1729167234",
"narration": "Wallet withdrawal",
"settlement_type": "instant"
}2. Vendor Payout System
Automate payments to vendors, suppliers, or freelancers.
Workflow:
- Vendor submits invoice or completes milestone
- Admin approves payment
- System creates transfer with invoice reference
- Webhook confirms payment
- Mark invoice as paid and notify vendor
Example:
{
"source_bank_account_id": "bank_platform_main",
"destination": {
"type": "bank_account",
"bank_code": "044",
"account_number": "0987654321",
"account_name": "ACME SUPPLIES LTD"
},
"amount": 25000000,
"currency": "NGN",
"reference": "invoice_INV-2024-001",
"narration": "Payment for Invoice INV-2024-001",
"settlement_type": "t1"
}3. Affiliate Commission Payouts
Pay affiliates or referral commissions automatically.
Workflow:
- Calculate commissions monthly/weekly
- Batch process all payouts with unique references
- Track each transfer by affiliate ID
- Reconcile with webhook events
- Generate payout reports
Example:
{
"source_bank_account_id": "bank_platform_main",
"destination": {
"type": "bank_account",
"bank_code": "058",
"account_number": "2233445566",
"account_name": "JANE AFFILIATE"
},
"amount": 150000,
"currency": "NGN",
"reference": "commission_aff_789_jan2024",
"narration": "January 2024 commission",
"settlement_type": "instant"
}4. Refund Processing
Process refunds to customers for canceled orders or disputes.
Workflow:
- Customer requests refund
- Admin approves refund
- Retrieve original payment details
- Create transfer to customer's bank account
- Use order ID in reference for reconciliation
- Update order status when webhook confirms
Example:
{
"source_bank_account_id": "bank_platform_main",
"destination": {
"type": "bank_account",
"bank_code": "033",
"account_number": "5566778899",
"account_name": "CUSTOMER NAME"
},
"amount": 3500000,
"currency": "NGN",
"reference": "refund_order_ORD12345",
"narration": "Refund for order ORD12345",
"settlement_type": "instant"
}5. Scheduled Salary Payments
Automate monthly salary disbursements to employees.
Workflow:
- Prepare employee payout list on payday
- Validate all account details with name enquiry
- Create transfers for each employee
- Use T+1 settlement for lower fees on bulk payments
- Track completion via webhooks
- Generate payslips and send confirmations
Example:
{
"source_bank_account_id": "bank_platform_payroll",
"destination": {
"type": "bank_account",
"bank_code": "057",
"account_number": "1122334455",
"account_name": "EMPLOYEE FULL NAME"
},
"amount": 50000000,
"currency": "NGN",
"reference": "salary_emp_456_jan2024",
"narration": "January 2024 Salary",
"settlement_type": "t1"
}π§ Best Practices
1. Always Validate Account Details First
Use the name enquiry endpoint before creating transfers to prevent sending money to wrong accounts.
curl -X POST "https://sandbox.tsara.ng/v1/fiat/transfers/name-enquiry" \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"bank_code": "035",
"account_number": "1234567890"
}'Show the returned account name to the user for confirmation before proceeding with the transfer.
2. Use Unique References for Idempotency
Always generate unique references to prevent duplicate transfers if the same request is retried.
Good Reference Patterns:
withdrawal_user_USER_ID_{timestamp}payout_invoice_{invoice_id}commission_affiliate_{affiliate_id}_{month}refund_order_{order_id}
Bad Practice:
{
"reference": "payout_1"
}If the request fails and you retry with the same reference, Tsara will reject it as a duplicate.
3. Rely on Webhooks, Not Polling
Set up webhook endpoints to receive real-time transfer status updates instead of polling the API.
Why Webhooks?
- Instant notifications (no delay)
- Reduces API calls (lower rate limit usage)
- More reliable than polling
- Automatic retry with exponential backoff
Implementation:
app.post('/webhooks/tsara', (req, res) => {
const event = req.body;
if (event.type === 'fiat.sent') {
const transfer = event.data;
updateTransferStatus(transfer.reference, 'completed');
notifyUser(transfer.reference, 'Your payout was successful');
}
if (event.type === 'fiat.failed') {
const transfer = event.data;
updateTransferStatus(transfer.reference, 'failed');
notifyUser(transfer.reference, `Payout failed: ${transfer.failure_reason}`);
}
res.status(200).send('OK');
});4. Store Both Reference and Transfer ID
Always save both your custom reference and Tsara's transfer ID for complete reconciliation.
Database Schema Example:
CREATE TABLE transfers (
id SERIAL PRIMARY KEY,
tsara_transfer_id VARCHAR(100) NOT NULL,
custom_reference VARCHAR(100) UNIQUE NOT NULL,
amount INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);This allows you to:
- Query by your reference when user asks for status
- Query by Tsara ID when processing webhooks
- Full audit trail for accounting
5. Handle Failed Transfers Gracefully
Not all transfers succeed. Implement proper error handling and user communication.
Common Failure Scenarios:
- Invalid account number
- Account name mismatch
- Insufficient balance
- Bank network downtime
- Daily transfer limits exceeded
Implementation:
async function handleTransferWebhook(event) {
if (event.type === 'fiat.failed') {
const transfer = event.data;
const user = await getUserByReference(transfer.reference);
await refundUserBalance(user.id, transfer.amount);
await sendNotification(user.id, {
title: 'Withdrawal Failed',
message: `We couldn't complete your withdrawal: ${transfer.failure_reason}. Your balance has been restored.`
});
await logFailure(transfer.transfer_id, transfer.failure_reason);
}
}6. Choose Settlement Type Based on Use Case
Select the right settlement speed to balance cost and user experience.
| Scenario | Recommended Settlement | Reason |
|---|---|---|
| Customer withdrawals | Instant | Users expect quick access to funds |
| Bulk vendor payments | T+1 | Lower fees for large batches |
| Refunds | Instant | Better customer experience |
| Monthly salaries | T+1 | Predictable next-day arrival, lower cost |
| Urgent emergency payouts | Instant | Speed is critical |
7. Implement Transfer Limits and Validation
Add business logic validation before creating transfers.
Recommended Validations:
- Minimum transfer amount (e.g., β¦100)
- Maximum transfer amount per transaction (e.g., β¦5,000,000)
- Daily transfer limit per user (e.g., β¦10,000,000)
- Verify account name matches user's registered name
- Check user KYC status for large amounts
- Validate bank code exists
- Ensure 10-digit NUBAN format
Example:
async function validateTransfer(userId, amount, accountNumber, bankCode) {
if (amount < 10000) {
throw new Error('Minimum withdrawal is β¦100');
}
if (amount > 500000000) {
throw new Error('Maximum withdrawal is β¦5,000,000');
}
const dailyTotal = await getDailyTransferTotal(userId);
if (dailyTotal + amount > 1000000000) {
throw new Error('Daily limit exceeded');
}
if (!/^\d{10}$/.test(accountNumber)) {
throw new Error('Account number must be 10 digits');
}
const banks = await getBankList();
if (!banks.find(b => b.bankCode === bankCode)) {
throw new Error('Invalid bank code');
}
return true;
}8. Cache Bank List Locally
Fetch and cache the bank list instead of querying it for every transfer.
Why Cache?
- Faster user experience
- Reduces API calls
- Bank list rarely changes
Implementation:
let bankListCache = null;
let cacheExpiry = null;
async function getBankList() {
if (bankListCache && cacheExpiry > Date.now()) {
return bankListCache;
}
const response = await fetch('https://sandbox.tsara.ng/v1/fiat/transfers/banks', {
headers: { 'Authorization': `Bearer ${SECRET_KEY}` }
});
const data = await response.json();
bankListCache = data.data;
cacheExpiry = Date.now() + (24 * 60 * 60 * 1000);
return bankListCache;
}π§ Troubleshooting
1. Transfer Stuck in "Processing" Status
Symptoms:
- Transfer status remains "processing" for more than 30 minutes
- No webhook received
- User complaining about delayed payout
Causes:
- Bank network downtime or delays
- Beneficiary bank processing issues
- Weekend/holiday processing delays
Solutions:
- Check transfer status via API:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers?id=trf_123" \
-H "Authorization: Bearer YOUR_SECRET_KEY"- Verify webhook endpoint is working:
curl -X POST "https://your-domain.com/webhooks/tsara" \
-H "Content-Type: application/json" \
-d '{"test": true}'-
Contact Tsara support if stuck for > 2 hours during business hours
-
Inform user of expected timeline based on settlement type:
- Instant: 5-30 minutes
- T+1: Next business day by 5 PM
2. "Account Name Mismatch" Error
Symptoms:
- Transfer fails immediately
- Error message: "Account name does not match bank records"
Causes:
- Name provided doesn't match bank's records exactly
- Extra spaces, special characters, or case differences
- Abbreviated names vs full names
Solutions:
- Always use name enquiry endpoint first:
curl -X POST "https://sandbox.tsara.ng/v1/fiat/transfers/name-enquiry" \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"bank_code": "035",
"account_number": "1234567890"
}'-
Use the exact name returned by name enquiry in your transfer request
-
Normalize names before comparison (trim spaces, uppercase)
-
Show the bank's account name to user for confirmation:
We found this account:
Bank: Wema Bank
Account Number: 1234567890
Account Name: JOHN VICTOR DOE
Is this correct?
[Yes] [No]
3. "Duplicate Reference" Error
Symptoms:
- Transfer creation fails with 400 error
- Error message: "Reference already used"
Causes:
- Retrying failed request with same reference
- Reference generation logic is not unique
- Multiple users/processes using same reference format
Solutions:
- Generate truly unique references:
function generateTransferReference(userId, purpose) {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
return `${purpose}_user_$USERID_${timestamp}_${random}`;
}- Check database before creating transfer:
const existingTransfer = await Transfer.findOne({ reference });
if (existingTransfer) {
return existingTransfer;
}- For retry scenarios, retrieve the original transfer:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers?reference=payout_001" \
-H "Authorization: Bearer YOUR_SECRET_KEY"4. "Insufficient Balance" Error
Symptoms:
- Transfer fails with 400 error
- Error message: "Insufficient balance in source account"
Causes:
- Source account doesn't have enough funds
- Fees not accounted for in balance check
- Concurrent transfers depleting balance
Solutions:
- Check account balance before transfer:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/accounts/bank_123/balance" \
-H "Authorization: Bearer YOUR_SECRET_KEY"- Account for fees in balance calculation:
function canAffordTransfer(balance, amount, settlementType) {
const feePercentage = settlementType === 'instant' ? 0.02 : 0.015;
const totalCost = amount + (amount * feePercentage);
return balance >= totalCost;
}- Implement balance locking for concurrent operations:
await db.transaction(async (trx) => {
const account = await trx('accounts')
.where({ id: accountId })
.forUpdate()
.first();
if (account.balance < totalCost) {
throw new Error('Insufficient balance');
}
await trx('accounts')
.where({ id: accountId })
.decrement('balance', totalCost);
await createTransfer(transferData);
});- Set up low balance alerts:
if (account.balance < 100000000) {
await notifyAdmin('Account balance is low: β¦' + (account.balance / 100));
}5. Webhook Not Received
Symptoms:
- Transfer completes but no webhook notification
- Application not updating transfer status
- Users not getting notifications
Causes:
- Webhook URL not configured or incorrect
- Firewall blocking Tsara's IP addresses
- Webhook endpoint returning errors
- HTTPS certificate issues
Solutions:
-
Verify webhook URL is configured in Tsara dashboard
-
Test webhook endpoint accessibility:
curl -X POST "https://your-domain.com/webhooks/tsara" \
-H "Content-Type: application/json" \
-d '{
"id": "evt_test",
"type": "fiat.sent",
"data": {
"transfer_id": "trf_test",
"status": "success"
}
}'-
Check webhook endpoint logs for errors
-
Ensure endpoint returns 200 status quickly:
app.post('/webhooks/tsara', async (req, res) => {
res.status(200).send('OK');
processWebhookAsync(req.body);
});- Implement fallback polling as backup:
async function pollTransferStatus(transferId) {
const maxAttempts = 10;
const interval = 30000;
for (let i = 0; i < maxAttempts; i++) {
const transfer = await getTransferStatus(transferId);
if (transfer.status !== 'processing') {
return transfer;
}
await sleep(interval);
}
throw new Error('Transfer polling timeout');
}6. "Invalid Bank Code" Error
Symptoms:
- Transfer creation fails with 400 error
- Error message: "Bank code does not exist"
Causes:
- Using outdated or incorrect bank code
- Typo in bank code
- Using bank name instead of code
Solutions:
- Fetch current bank list:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers/banks" \
-H "Authorization: Bearer YOUR_SECRET_KEY"-
Cache bank list and refresh daily (see Best Practice #8)
-
Validate bank code before transfer:
function validateBankCode(bankCode, bankList) {
const bank = bankList.find(b => b.bankCode === bankCode);
if (!bank) {
throw new Error(`Invalid bank code: ${bankCode}`);
}
return bank;
}- Build user-friendly bank selector:
const bankOptions = bankList.map(bank => ({
value: bank.bankCode,
label: `${bank.name} (${bank.bankCode})`
}));π Security Considerations
1. Webhook Signature Verification
Always verify webhook signatures to ensure requests are from Tsara.
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha512', secret);
const digest = hmac.update(JSON.stringify(payload)).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
}
app.post('/webhooks/tsara', (req, res) => {
const signature = req.headers['x-tsara-signature'];
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
processWebhook(req.body);
res.status(200).send('OK');
});2. Rate Limiting on Transfer Endpoints
Implement rate limiting to prevent abuse and fraud.
const rateLimit = require('express-rate-limit');
const transferLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: 'Too many transfer requests, please try again later'
});
app.post('/api/create-transfer', transferLimiter, async (req, res) => {
});3. IP Whitelisting
Whitelist Tsara's IP addresses for webhook endpoints.
const TSARA_IPS = ['52.31.12.123', '54.76.45.67'];
function validateTsaraIP(req, res, next) {
const clientIP = req.ip;
if (!TSARA_IPS.includes(clientIP)) {
return res.status(403).send('Forbidden');
}
next();
}
app.post('/webhooks/tsara', validateTsaraIP, handleWebhook);4. Audit Logging
Log all transfer operations for compliance and debugging.
async function logTransfer(userId, transferData, result) {
await AuditLog.create({
user_id: userId,
action: 'bank_transfer',
amount: transferData.amount,
destination: transferData.destination.account_number,
reference: transferData.reference,
status: result.status,
tsara_transfer_id: result.data?.id,
ip_address: req.ip,
timestamp: new Date()
});
}5. Two-Factor Authentication for Large Amounts
Require additional verification for transfers above certain thresholds.
async function createTransfer(userId, transferData) {
const LARGE_AMOUNT_THRESHOLD = 100000000;
if (transferData.amount > LARGE_AMOUNT_THRESHOLD) {
const otpVerified = await verifyOTP(userId, req.body.otp);
if (!otpVerified) {
throw new Error('OTP verification required for large transfers');
}
}
return await tsara.createTransfer(transferData);
}π Related Pages
- Virtual Accounts β Create virtual accounts to receive NGN payments
- Wallets β Manage USDC wallets and transfers
- Webhooks Security β Secure your webhook endpoints
- Customers β Manage customer identities for transfers
- Identity Verification β Verify customer identities for compliance