Transfers

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

TypeDescriptionSpeedFeeUse Case
InternalTsara account to Tsara accountInstantFREEBusiness-to-business settlements, affiliate payouts
ExternalTsara account to external bank5-30 minutes2% (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

ParameterTypeRequiredDescription
source_bank_account_idstringRequiredYour Tsara virtual bank account ID (e.g., bank_123)
destinationobjectRequiredDestination bank account details
destination.typestringRequiredMust be bank_account
destination.bank_codestringRequiredNigerian bank code (e.g., 035 for Wema Bank). Get from /fiat/transfers/banks
destination.account_numberstringRequired10-digit NUBAN account number
destination.account_namestringRequiredAccount holder name (must match bank records)
amountintegerRequiredAmount in kobo (25000 = ₦250.00)
currencystringRequiredMust be NGN
referencestringRequiredUnique reference for idempotency (max 100 chars)
narrationstringOptionalTransfer description shown on bank statement (max 100 chars)
settlement_typestringOptionalinstant (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

FieldTypeDescription
successbooleanWhether request was successful
dataobjectTransfer details
data.idstringUnique transfer ID (e.g., trf_123)
data.source_bank_account_idstringSource account ID
data.amountintegerAmount transferred in kobo
data.currencystringCurrency code (NGN)
data.destinationobjectDestination account details
data.destination.bank_namestringBank name (e.g., Wema Bank)
data.destination.account_numberstringAccount number
data.destination.account_namestringAccount holder name
data.statusstringTransfer status: processing, success, or failed
data.referencestringYour unique reference
data.narrationstringTransfer description
data.feeintegerTransfer fee charged in kobo
data.settlement_typestringSettlement speed chosen
data.created_atstringISO 8601 timestamp
data.completed_atstringISO 8601 timestamp (null if still processing)
request_idstringUnique 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

StatusDescriptionNext Actions
processingTransfer submitted to bank networkWait for webhook or poll status
successFunds successfully deliveredUpdate user balance, send confirmation
failedTransfer 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

ParameterTypeRequiredDescription
idstringRequiredTransfer 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

FieldTypeDescription
successbooleanWhether request was successful
dataobjectTransfer details (same structure as create response)
data.idstringTransfer ID
data.amountintegerAmount in kobo
data.currencystringCurrency code
data.destinationobjectDestination bank details
data.statusstringCurrent transfer status
data.referencestringYour unique reference
data.failure_reasonstringReason for failure (only present if status is failed)
data.created_atstringCreation timestamp
data.completed_atstringCompletion 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

ParameterTypeRequiredDescription
pageintegerOptionalPage number (default: 1)
limitintegerOptionalResults per page (default: 20, max: 100)
statusstringOptionalFilter by status: processing, success, or failed
referencestringOptionalFilter by your reference ID
start_datestringOptionalFilter transfers from this date (ISO 8601)
end_datestringOptionalFilter 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

FieldTypeDescription
successbooleanWhether request was successful
dataarrayArray of transfer objects
data[].idstringTransfer ID
data[].amountintegerAmount in kobo
data[].statusstringTransfer status
data[].destinationobjectDestination details
data[].referencestringYour reference
data[].created_atstringCreation timestamp
paginationobjectPagination details
pagination.pageintegerCurrent page number
pagination.limitintegerResults per page
pagination.totalintegerTotal number of transfers
pagination.total_pagesintegerTotal 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

ParameterTypeRequiredDescription
pageintegerOptionalPage number (default: 1)
limitintegerOptionalResults 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

FieldTypeDescription
successbooleanWhether request was successful
dataarrayArray of bank objects
data[].namestringOfficial bank name
data[].bankCodestringBank code to use in transfers
data[].aliasarrayAlternative names for the bank
data[].routingKeystringCBN routing key
data[].nubanCodestringNUBAN code (may be null)
data[].categoryIdstringBank category identifier
data[].logoImagestringURL to bank logo (may be null)
paginationobjectPagination 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

ParameterTypeRequiredDescription
bank_codestringRequiredBank code (e.g., 035 for Wema Bank)
account_numberstringRequired10-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

FieldTypeDescription
successbooleanWhether request was successful
statusstringResponse status (e.g., success)
status_codeintegerHTTP status code
messagestringHuman-readable message
dataobjectAccount details
data.bank_codestringBank code queried
data.account_numberstringAccount number queried
data.account_namestringAccount 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 TypeDescriptionWhen Triggered
fiat.sentTransfer successfully completedFunds delivered to destination account
fiat.failedTransfer failedInvalid account, insufficient funds, or bank rejection

Webhook Payload Structure

FieldTypeDescription
idstringUnique webhook event ID
typestringEvent type (fiat.sent or fiat.failed)
dataobjectTransfer details
data.transfer_idstringTransfer ID
data.amountintegerAmount in kobo
data.currencystringCurrency code
data.referencestringYour unique reference
data.statusstringTransfer status (success or failed)
data.destinationobjectDestination bank details
data.failure_reasonstringReason for failure (only in fiat.failed events)
data.created_atstringTransfer creation timestamp
data.completed_atstringTransfer 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 TypeFeeProcessing TimeBest For
Instant2%5-30 minutesUrgent payouts, customer withdrawals
T+11.5%Next business dayBulk vendor payments, scheduled payouts
InternalFREEInstantTsara-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 CodeMessageCauseSolution
400Invalid bank codeBank code doesn't existUse /fiat/transfers/banks to get valid codes
400Invalid account numberAccount number format is wrongEnsure 10-digit NUBAN format
400Account name mismatchProvided name doesn't match bank recordsUse name enquiry endpoint first
400Insufficient balanceSource account has insufficient fundsCheck account balance before transfer
400Duplicate referenceReference already usedUse unique reference for each transfer
401UnauthorizedInvalid or missing API keyCheck Authorization header with Secret Key
404Transfer not foundTransfer ID doesn't existVerify transfer ID is correct
422Invalid amountAmount is zero, negative, or exceeds limitCheck amount is positive and within limits
500Transfer processing failedBank network errorRetry 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:

  1. User requests withdrawal in your app
  2. Validate user has sufficient balance
  3. Show name enquiry to confirm account details
  4. User confirms withdrawal
  5. Create transfer with unique reference (user_id + timestamp)
  6. Update user balance to "pending withdrawal"
  7. Listen for webhook to confirm completion
  8. 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:

  1. Vendor submits invoice or completes milestone
  2. Admin approves payment
  3. System creates transfer with invoice reference
  4. Webhook confirms payment
  5. 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:

  1. Calculate commissions monthly/weekly
  2. Batch process all payouts with unique references
  3. Track each transfer by affiliate ID
  4. Reconcile with webhook events
  5. 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:

  1. Customer requests refund
  2. Admin approves refund
  3. Retrieve original payment details
  4. Create transfer to customer's bank account
  5. Use order ID in reference for reconciliation
  6. 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:

  1. Prepare employee payout list on payday
  2. Validate all account details with name enquiry
  3. Create transfers for each employee
  4. Use T+1 settlement for lower fees on bulk payments
  5. Track completion via webhooks
  6. 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.

ScenarioRecommended SettlementReason
Customer withdrawalsInstantUsers expect quick access to funds
Bulk vendor paymentsT+1Lower fees for large batches
RefundsInstantBetter customer experience
Monthly salariesT+1Predictable next-day arrival, lower cost
Urgent emergency payoutsInstantSpeed 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:

  1. Check transfer status via API:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers?id=trf_123" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"
  1. Verify webhook endpoint is working:
curl -X POST "https://your-domain.com/webhooks/tsara" \
  -H "Content-Type: application/json" \
  -d '{"test": true}'
  1. Contact Tsara support if stuck for > 2 hours during business hours

  2. 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:

  1. 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"
  }'
  1. Use the exact name returned by name enquiry in your transfer request

  2. Normalize names before comparison (trim spaces, uppercase)

  3. 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:

  1. 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}`;
}
  1. Check database before creating transfer:
const existingTransfer = await Transfer.findOne({ reference });
if (existingTransfer) {
  return existingTransfer;
}
  1. 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:

  1. Check account balance before transfer:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/accounts/bank_123/balance" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"
  1. 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;
}
  1. 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);
});
  1. 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:

  1. Verify webhook URL is configured in Tsara dashboard

  2. 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"
    }
  }'
  1. Check webhook endpoint logs for errors

  2. Ensure endpoint returns 200 status quickly:

app.post('/webhooks/tsara', async (req, res) => {
  res.status(200).send('OK');

  processWebhookAsync(req.body);
});
  1. 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:

  1. Fetch current bank list:
curl -X GET "https://sandbox.tsara.ng/v1/fiat/transfers/banks" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"
  1. Cache bank list and refresh daily (see Best Practice #8)

  2. 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;
}
  1. 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