Virtual Account

The Virtual Bank Accounts API allows you to create and manage fiat accounts for your business or customers.
Each virtual account behaves like a regular bank account — it can receive deposits, display balances, and participate in transfers — all through Tsara's infrastructure.

Currently, virtual accounts are supported for NGN (Nigerian Naira).


Overview

  • Create new virtual bank accounts (dynamic or reserved)
  • Retrieve account details and balances
  • List transactions with filtering
  • Choose settlement options (Instant or T+1)
  • Get webhook notifications on deposits or activity
  • Support both temporary and permanent accounts

Key Features

FeatureDescription
Instant Account CreationGenerate accounts in seconds via API
Real Nigerian BanksPowered by SafeHaven MFB, GTBank, and other partner banks
Automatic SettlementFunds automatically settle to your Tsara balance
Webhook NotificationsReal-time alerts for all transactions
Multiple Account TypesDynamic (temporary) and Reserved (permanent)

Virtual Account Types

Understanding the difference between account types:

FeatureDynamic Virtual AccountReserved Virtual Account
LifespanTemporary (expires after time limit or first payment)Permanent (never expires)
Use CaseSingle payment, invoices, one-time checkoutsCustomer wallets, recurring payments, long-term use
CreationNo customer identity requiredRequires verified customer identity
ReusabilitySingle-use or time-limitedUnlimited reuse
Account NameGeneric business nameCustomer's actual name (from identity verification)
Best ForE-commerce checkouts, invoicesSubscription services, user wallets, savings

Create a Dynamic Virtual Account

Generate a temporary virtual account for one-time or time-limited payments.

Endpoint

POST /fiat/virtual-accounts

Headers

HeaderValueRequired
AuthorizationBearer YOUR_SECRET_KEYYes
Content-Typeapplication/jsonYes

Request Parameters

ParameterTypeRequiredDescriptionExample
currencystringYesCurrency code. Must be "NGN""NGN"
amountnumberNoExpected payment amount in kobo (for validation)500000 (₦5,000.00)
validFornumberNoAccount validity in seconds (default: 86400 = 24 hours, max: 2592000 = 30 days)1800 (30 minutes)
business_idstringNoYour internal business/order reference (max 100 chars)"order_12345"
settlement_optionstringNoSettlement speed: "instant" or "T+1" (default: "instant")"instant"
metadataobjectNoCustom tracking data (max 10 keys, 500 chars per value){"order_id": "ORD-123"}

Settlement Options Explained

OptionDescriptionSettlement TimeFeesBest For
instantFunds settle immediately to your balanceReal-timeStandardImmediate fulfillment needs
T+1Funds settle next business day1 business dayLower feesHigh-volume, non-urgent
defaultUses your account default settingVariesAs configuredGeneral use

Important Notes

  • Amount validation: If provided, account will only accept the exact amount specified
  • Expiration: Accounts auto-expire after validFor seconds or after first successful payment (whichever comes first)
  • Account name: Will show your business name (configured in dashboard)
  • Reusability: Dynamic accounts are typically single-use

Request Example

{
  "currency": "NGN",
  "amount": 500000,
  "validFor": 1800,
  "business_id": "order_12345",
  "settlement_option": "instant",
  "metadata": {
    "order_id": "ORD-123",
    "customer_email": "[email protected]",
    "product": "Premium Plan"
  }
}
curl -X POST "https://sandbox.tsara.ng/v1/fiat/virtual-accounts" \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "NGN",
    "amount": 500000,
    "validFor": 1800,
    "business_id": "order_12345",
    "settlement_option": "instant",
    "metadata": {
      "order_id": "ORD-123"
    }
  }'

Response

{
  "success": true,
  "status": "success",
  "status_code": 200,
  "message": "Virtual account created successfully",
  "data": {
    "id": "bank_695fc9b9d4e992",
    "uid": "bank_695fc9b9d4e992",
    "reference": "ref_1738318449",
    "bank_name": "SafeHaven MFB",
    "bank_code": "090286",
    "account_number": "6028678511",
    "account_name": "Acme Ltd",
    "currency": "NGN",
    "type": "dynamic",
    "expected_amount": 500000,
    "settlement_option": "instant",
    "status": "active",
    "expires_at": "2025-01-31T12:30:00Z",
    "metadata": {
      "order_id": "ORD-123",
      "customer_email": "[email protected]"
    },
    "created_at": "2025-01-31T12:00:00Z",
    "updated_at": "2025-01-31T12:00:00Z"
  },
  "request_id": "req_1738318449"
}

Response Fields

FieldTypeDescription
successbooleanRequest success status
statusstringRequest status text
status_codenumberHTTP status code
messagestringHuman-readable message
data.idstringVirtual account ID
data.referencestringUnique account reference
data.bank_namestringNigerian bank name
data.bank_codestringNigerian bank code (NIBSS code)
data.account_numberstring10-digit Nigerian account number
data.account_namestringAccount name shown to payers
data.currencystringCurrency code (NGN)
data.typestringAccount type: dynamic or reserved
data.expected_amountnumberExpected payment amount (null if not specified)
data.settlement_optionstringSettlement speed
data.statusstringAccount status: active, expired, closed
data.expires_atstringExpiration timestamp (ISO 8601)
data.metadataobjectCustom metadata
data.created_atstringCreation timestamp
data.updated_atstringLast update timestamp

💡 The account_number and bank_name can be shared with customers to receive NGN deposits.

Account Status Values

StatusDescription
activeAccount is operational and can receive deposits
expiredAccount has passed expiration time
closedAccount manually closed or received payment (for single-use)
suspendedAccount temporarily disabled (contact support)

Error Responses

{
  "success": false,
  "status_code": 400,
  "error": {
    "code": "validation_error",
    "message": "Invalid request parameters",
    "details": [
      {
        "field": "amount",
        "message": "Amount must be greater than 100 (₦1.00)"
      }
    ]
  }
}

Common Errors

Status CodeError CodeDescription
400validation_errorInvalid request parameters
400invalid_currencyCurrency not supported (use NGN)
400invalid_validity_periodvalidFor exceeds maximum (30 days)
401unauthorizedInvalid or missing API key
429rate_limit_exceededToo many accounts created

Create a Reserved Virtual Account

Generate a permanent virtual account tied to a verified customer identity.

Endpoint

POST /fiat/reserved-accounts

Headers

HeaderValueRequired
AuthorizationBearer YOUR_SECRET_KEYYes
Content-Typeapplication/jsonYes

Request Parameters

ParameterTypeRequiredDescriptionExample
currencystringYesCurrency code. Must be "NGN""NGN"
identity_idstringYesID of verified customer identity (from Identity Verification API)"identity_abc123"
phone_numberstringYesCustomer phone number (11 digits, starts with 0)"08012345678"
emailstringYesCustomer email address"[email protected]"
referencestringNoYour unique reference for this account (max 100 chars)"user_123_account"
metadataobjectNoCustom tracking data{"user_id": "usr_123"}

⚠️ Important: Identity ID Requirement

Reserved accounts require a verified customer identity because:

  • Account name shows customer's actual name (from BVN/NIN verification)
  • Ensures KYC compliance for permanent accounts
  • Prevents account misuse and fraud

How to get identity_id:

  1. Create customer: POST /customers
  2. Verify identity: POST /customers/identity/initiate (BVN or NIN)
  3. Validate verification: POST /customers/identity/validate (with OTP)
  4. Use returned identity_id to create reserved account

See Identity Verification for details.

Request Example

{
  "currency": "NGN",
  "identity_id": "identity_abc123",
  "phone_number": "08012345678",
  "email": "[email protected]",
  "reference": "user_123_main_account",
  "metadata": {
    "user_id": "usr_123",
    "account_type": "savings"
  }
}
curl -X POST "https://sandbox.tsara.ng/v1/fiat/reserved-accounts" \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "NGN",
    "identity_id": "identity_abc123",
    "phone_number": "08012345678",
    "email": "[email protected]",
    "reference": "user_123_main_account",
    "metadata": {
      "user_id": "usr_123"
    }
  }'

Response

{
  "success": true,
  "status": "success",
  "status_code": 200,
  "message": "Reserved account created successfully",
  "data": {
    "id": "bank_reserved_123",
    "reference": "user_123_main_account",
    "bank_name": "GTBank",
    "bank_code": "058",
    "account_number": "0123456789",
    "account_name": "John Doe",
    "currency": "NGN",
    "type": "reserved",
    "customer_id": "cus_123",
    "identity_id": "identity_abc123",
    "status": "active",
    "expires_at": null,
    "metadata": {
      "user_id": "usr_123",
      "account_type": "savings"
    },
    "created_at": "2025-01-31T12:00:00Z",
    "updated_at": "2025-01-31T12:00:00Z"
  }
}

Reserved Account Features

  • Permanent: Never expires (unless manually closed)
  • Personal name: Shows customer's real name from identity verification
  • Unlimited use: Can receive unlimited deposits
  • Consistent: Same account number for all transactions
  • Traceable: Linked to verified customer identity

Retrieve a Virtual Account

Get account details by ID or reference.

Endpoint

GET /fiat/virtual-accounts?id={account_id}

or

GET /fiat/virtual-accounts?reference={account_reference}

Query Parameters

ParameterTypeRequiredDescription
idstringNo*Virtual account ID
referencestringNo*Your account reference

*At least one parameter required

Example Request

curl -X GET "https://sandbox.tsara.ng/v1/fiat/virtual-accounts?id=bank_695fc9b9d4e992" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"

Response

{
  "success": true,
  "status": "success",
  "status_code": 200,
  "message": "Virtual account retrieved",
  "data": {
    "id": "bank_695fc9b9d4e992",
    "reference": "ref_1738318449",
    "bank_name": "SafeHaven MFB",
    "bank_code": "090286",
    "account_number": "6028678511",
    "account_name": "Acme Ltd",
    "currency": "NGN",
    "type": "dynamic",
    "balance": 0,
    "total_received": 0,
    "payment_count": 0,
    "settlement_option": "instant",
    "status": "active",
    "expires_at": "2025-01-31T12:30:00Z",
    "created_at": "2025-01-31T12:00:00Z",
    "last_payment_at": null
  }
}

Get Account Balance

Retrieve current balance for a virtual account.

Endpoint

GET /fiat/virtual-accounts/balance?id={account_id}

Query Parameters

ParameterTypeRequiredDescription
idstringYesVirtual account ID

Example Request

curl -X GET "https://sandbox.tsara.ng/v1/fiat/virtual-accounts/balance?id=bank_695fc9b9d4e992" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"

Response

{
  "success": true,
  "status": "success",
  "status_code": 200,
  "message": "Account balance retrieved",
  "data": {
    "account_id": "bank_695fc9b9d4e992",
    "available_balance": 250000,
    "pending_balance": 50000,
    "total_balance": 300000,
    "currency": "NGN",
    "last_settlement_at": "2025-01-31T10:00:00Z",
    "updated_at": "2025-01-31T12:10:00Z"
  }
}

Balance Fields

FieldDescription
available_balanceSettled funds available for withdrawal
pending_balanceFunds pending settlement (T+1 accounts)
total_balanceSum of available + pending

List Transactions

Retrieve transaction history for virtual accounts.

Endpoint

GET /fiat/virtual-accounts/transactions?page={page}&limit={limit}

Query Parameters

ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Items per page (max: 100)
account_idstringNo-Filter by account ID
typestringNo-Filter by type: incoming, outgoing
statusstringNo-Filter by status: success, pending, failed
from_datestringNo-Start date (ISO 8601)
to_datestringNo-End date (ISO 8601)

Example Request

curl -X GET "https://sandbox.tsara.ng/v1/fiat/virtual-accounts/transactions?account_id=bank_695fc9b9d4e992&page=1&limit=20" \
  -H "Authorization: Bearer YOUR_SECRET_KEY"

Response

{
  "success": true,
  "status": "success",
  "status_code": 200,
  "message": "Transactions retrieved",
  "data": [
    {
      "id": "txn_001",
      "account_id": "bank_695fc9b9d4e992",
      "type": "incoming",
      "amount": 500000,
      "currency": "NGN",
      "description": "Customer payment",
      "status": "success",
      "reference": "order_001",
      "sender_bank": "Access Bank",
      "sender_account": "0987654321",
      "sender_name": "Jane Smith",
      "balance_before": 0,
      "balance_after": 500000,
      "settled_at": "2025-01-31T12:00:30Z",
      "created_at": "2025-01-31T12:00:00Z"
    },
    {
      "id": "txn_002",
      "account_id": "bank_695fc9b9d4e992",
      "type": "outgoing",
      "amount": 200000,
      "currency": "NGN",
      "description": "Vendor payout",
      "status": "success",
      "reference": "payout_001",
      "recipient_bank": "GTBank",
      "recipient_account": "0123456789",
      "recipient_name": "Vendor Co",
      "balance_before": 500000,
      "balance_after": 300000,
      "created_at": "2025-01-31T12:05:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 2,
    "total_pages": 1,
    "has_more": false
  },
  "request_id": "req_xyz"
}

Transaction Fields

FieldTypeDescription
typestringTransaction direction: incoming or outgoing
sender_bankstringBank of sender (for incoming)
sender_accountstringAccount number of sender
sender_namestringName of sender
balance_beforenumberAccount balance before transaction
balance_afternumberAccount balance after transaction
settled_atstringSettlement timestamp

Webhook Notifications

Real-time notifications for all virtual account activity.

Event Types

EventDescriptionTrigger
fiat.receivedDeposit receivedWhen customer sends money to virtual account
fiat.sentPayout completedWhen transfer from account succeeds
fiat.failedTransaction failedWhen deposit or payout fails
account.expiredAccount expiredWhen dynamic account passes expiration time

Example Webhook Payload: fiat.received

{
  "id": "evt_100",
  "type": "fiat.received",
  "created_at": "2025-01-31T12:00:00Z",
  "data": {
    "transaction_id": "txn_001",
    "account_id": "bank_695fc9b9d4e992",
    "account_number": "6028678511",
    "amount": 500000,
    "currency": "NGN",
    "reference": "order_001",
    "sender": {
      "bank": "Access Bank",
      "account_number": "0987654321",
      "account_name": "Jane Smith"
    },
    "balance_before": 0,
    "balance_after": 500000,
    "settlement_option": "instant",
    "status": "success",
    "metadata": {
      "order_id": "ORD-123"
    },
    "settled_at": "2025-01-31T12:00:30Z",
    "created_at": "2025-01-31T12:00:00Z"
  }
}

Webhook Handler Example

app.post('/webhooks/tsara', (req, res) => {
  const event = req.body;

  if (event.type === 'fiat.received') {
    const payment = event.data;
    const orderId = payment.metadata.order_id;

    await fulfillOrder(orderId);

    await db.payments.create({
      order_id: orderId,
      amount: payment.amount,
      transaction_id: payment.transaction_id,
      sender: payment.sender.account_name,
      status: 'completed'
    });

    await notifyCustomer(orderId, 'Payment received');
  }

  res.sendStatus(200);
});

See Webhooks Security for signature verification.


Use Cases & Examples

E-commerce Checkout

Create dynamic account for order payment.

async function createOrderPaymentAccount(orderId, orderAmount) {
  const response = await fetch('https://sandbox.tsara.ng/v1/fiat/virtual-accounts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      currency: 'NGN',
      amount: orderAmount,
      validFor: 3600,
      business_id: `order_${orderId}`,
      settlement_option: 'instant',
      metadata: {
        order_id: orderId,
        customer_email: order.customer_email
      }
    })
  });

  const data = await response.json();
  const account = data.data;

  await db.orders.update(orderId, {
    payment_account_number: account.account_number,
    payment_bank: account.bank_name,
    payment_expires_at: account.expires_at
  });

  return account;
}

User Wallet System

Create reserved account for each user.

async function createUserWallet(userId) {
  const user = await db.users.findById(userId);
  const identity = await verifyUserIdentity(user.bvn);

  const response = await fetch('https://sandbox.tsara.ng/v1/fiat/reserved-accounts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      currency: 'NGN',
      identity_id: identity.id,
      phone_number: user.phone,
      email: user.email,
      reference: `user_$USERID_wallet`,
      metadata: {
        user_id: userId,
        account_type: 'wallet'
      }
    })
  });

  const data = await response.json();

  await db.users.update(userId, {
    wallet_account_id: data.data.id,
    wallet_account_number: data.data.account_number,
    wallet_bank_name: data.data.bank_name
  });

  return data.data;
}

Invoice Payment

Generate account for invoice with expiration.

async function generateInvoicePaymentAccount(invoiceId, amount, dueDate) {
  const validFor = Math.floor((new Date(dueDate) - Date.now()) / 1000);

  const response = await fetch('https://sandbox.tsara.ng/v1/fiat/virtual-accounts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      currency: 'NGN',
      amount: amount,
      validFor: validFor,
      business_id: `invoice_${invoiceId}`,
      metadata: {
        invoice_id: invoiceId,
        due_date: dueDate
      }
    })
  });

  return response.json();
}

Tips & Best Practices

  1. Use amount validation for exact payments

    {
      amount: expectedAmount,
      validFor: 1800
    }

    Account will only accept the exact amount.

  2. Set appropriate expiration times

    validFor: {
      'checkout': 1800,        // 30 minutes
      'invoice': 86400 * 7,    // 7 days
      'quote': 3600            // 1 hour
    }
  3. Store account details

    await db.virtual_accounts.create({
      account_id: account.id,
      account_number: account.account_number,
      bank_name: account.bank_name,
      order_id: orderId,
      expires_at: account.expires_at
    });
  4. Handle duplicate deposits

    await db.payments.findOrCreate({
      where: { transaction_id: payment.transaction_id },
      defaults: {
        order_id: orderId,
        amount: payment.amount
      }
    });
  5. Monitor expiration

    cron.schedule('*/5 * * * *', async () => {
      const expiring = await db.virtual_accounts.findAll({
        where: {
          expires_at: { $lte: Date.now() + 300000 },
          status: 'pending'
        }
      });
    
      for (const account of expiring) {
        await notifyExpiringAccount(account);
      }
    });
  6. Choose settlement based on urgency

    settlement_option: isUrgent ? 'instant' : 'T+1'
  7. Use reserved accounts for repeat customers

    if (customer.repeat_customer) {
      return customer.reserved_account;
    }
    return await createDynamicAccount(order);

Troubleshooting

Account not receiving deposits

Cause: Account expired or customer using wrong details.

Solution:

  1. Check account status and expiration
  2. Verify account number and bank name shared correctly
  3. Ensure customer sending from Nigerian bank
  4. Check transaction limits (minimum ₦100)

Webhook not received

Cause: Webhook URL not configured or validation failing.

Solution:

  1. Configure webhook URL in dashboard
  2. Verify HMAC-SHA512 signature
  3. Ensure endpoint returns 200 OK within 10 seconds
  4. Check webhook logs in dashboard

Reserved account creation fails

Cause: Missing or invalid identity_id.

Solution:

// First verify identity
const identity = await verifyBVN(bvnNumber, otp);

// Then create account
const account = await createReservedAccount({
  identity_id: identity.id,
  phone_number: phone,
  email: email
});

Amount mismatch error

Cause: Customer sent different amount than expected.

Solution:

  • For strict validation, set amount parameter
  • For flexible amounts, omit amount parameter
  • Handle partial payments in webhook

Account expired before payment

Cause: validFor too short.

Solution:

const validFor = Math.max(1800, estimatedPaymentTime * 2);

Related Pages