Bonaventure OgetoBy Bonaventure Ogeto|

Reconciling Dedicated Virtual Account Deposits

To reconcile Dedicated Virtual Account deposits, listen for the dedicatedaccount.assign.success webhook when accounts are created and the charge.success webhook when deposits land. Match each deposit to a customer using the DVA account number or customer code. Check the amount against what was expected. Handle overpayments and underpayments with clear business logic. Run daily reconciliation against the Paystack transaction list to catch any missed webhooks.

How DVA Deposits Flow Through Your System

When you assign a Dedicated Virtual Account to a customer, Paystack generates a permanent bank account number. The customer transfers money to this number from any bank. Here is the flow:

  1. Customer transfers money to their DVA account number (e.g., via mobile banking app, USSD, or bank branch).
  2. The receiving bank notifies Paystack that a deposit landed.
  3. Paystack matches the deposit to the DVA and fires a charge.success webhook to your server.
  4. Your server processes the webhook, identifies the customer, and credits their account or fulfills their order.
  5. On the next settlement cycle, Paystack settles the deposit amount (minus fees) to your bank account.

The critical engineering challenge is step 4: correctly matching the deposit to the customer and handling the amount. Unlike card or USSD payments where you control the exact amount, DVA deposits are customer-initiated transfers. The customer can send any amount at any time.

// Webhook handler for DVA deposits
app.post('/webhooks/paystack', async (req, res) => {
  // Verify webhook signature first (omitted for brevity)
  const event = req.body;

  if (event.event === 'charge.success') {
    const data = event.data;

    // Check if this is a DVA deposit
    if (data.channel === 'dedicated_nuban') {
      const customerCode = data.customer.customer_code;
      const amount = data.amount; // in kobo
      const reference = data.reference;

      // Find the customer in your database
      const customer = await db.query(
        'SELECT id, email FROM customers WHERE paystack_customer_code = $1',
        [customerCode]
      );

      if (customer.rows.length > 0) {
        // Credit the customer's wallet or process the payment
        await processDeposit(customer.rows[0].id, amount, reference);
      } else {
        // Unknown customer deposit. Log for manual review.
        console.log('DVA deposit from unknown customer: ' + customerCode + ', amount: ' + amount);
      }
    }
  }

  res.sendStatus(200);
});

Matching Deposits to Customer Records

You need a reliable way to connect each DVA deposit to a customer in your system. There are two primary matching strategies:

Match by customer code: When you create a DVA through the Paystack API, you associate it with a Paystack customer. The webhook payload includes the customer.customer_code. Store this code in your customer table when you create the DVA, and match on it when deposits arrive.

// When creating the DVA, store the mapping
async function assignDVA(userId, email) {
  // First, create or fetch the Paystack customer
  const customerResponse = await fetch('https://api.paystack.co/customer', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email }),
  });
  const customerData = await customerResponse.json();

  // Create the DVA
  const dvaResponse = await fetch('https://api.paystack.co/dedicated_account', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      customer: customerData.data.id,
      preferred_bank: 'wema-bank',
    }),
  });
  const dvaData = await dvaResponse.json();

  // Store the mapping
  await db.query(
    'UPDATE customers SET paystack_customer_code = $1, dva_account_number = $2, dva_bank = $3 WHERE id = $4',
    [customerData.data.customer_code, dvaData.data.account_number, dvaData.data.bank.name, userId]
  );

  return dvaData.data;
}

Match by account number: The webhook payload includes the DVA account number. If you stored the account number in your customer record when you created the DVA, you can match on that instead.

// Alternative: match by DVA account number
const accountNumber = event.data.authorization.receiver_bank_account_number;
const customer = await db.query(
  'SELECT id FROM customers WHERE dva_account_number = $1',
  [accountNumber]
);

Use both matching strategies as fallbacks. Try the customer code first (more reliable), and fall back to the account number if needed.

Handling Overpayment and Underpayment

With DVAs, the customer controls the transfer amount. They might send exactly what you expected, more, or less. Your business logic needs to handle all three cases.

Exact match: The customer sends exactly the amount due. Process normally.

Overpayment: The customer sends more than required. Options:

  • Credit the excess to their wallet balance for future purchases.
  • Apply the excess to their next invoice automatically.
  • Initiate a refund for the excess amount via Paystack Transfers.

Underpayment: The customer sends less than required. Options:

  • Accept the partial payment and mark the order as partially paid. Notify the customer of the remaining balance.
  • Reject the payment entirely and refund the amount. Notify the customer to send the correct amount.
  • Credit their wallet with the amount they sent. Do not fulfill the order until the full amount is reached.
async function processDeposit(customerId, amountInKobo, reference) {
  // Find any pending invoice for this customer
  const invoice = await db.query(
    'SELECT id, amount_due FROM invoices WHERE customer_id = $1 AND status = $2 ORDER BY created_at ASC LIMIT 1',
    [customerId, 'pending']
  );

  if (invoice.rows.length === 0) {
    // No pending invoice. Credit to wallet.
    await db.query(
      'UPDATE customers SET wallet_balance = wallet_balance + $1 WHERE id = $2',
      [amountInKobo, customerId]
    );
    console.log('Credited ' + (amountInKobo / 100) + ' to wallet for customer ' + customerId);
    return;
  }

  const amountDue = invoice.rows[0].amount_due;

  if (amountInKobo >= amountDue) {
    // Full payment (or overpayment)
    await db.query(
      'UPDATE invoices SET status = $1, paid_at = $2, payment_reference = $3 WHERE id = $4',
      ['paid', new Date(), reference, invoice.rows[0].id]
    );

    if (amountInKobo > amountDue) {
      // Credit the excess to wallet
      const excess = amountInKobo - amountDue;
      await db.query(
        'UPDATE customers SET wallet_balance = wallet_balance + $1 WHERE id = $2',
        [excess, customerId]
      );
      console.log('Overpayment of ' + (excess / 100) + ' credited to wallet');
    }
  } else {
    // Underpayment
    const remaining = amountDue - amountInKobo;
    await db.query(
      'UPDATE invoices SET amount_due = $1 WHERE id = $2',
      [remaining, invoice.rows[0].id]
    );
    console.log('Partial payment. Remaining: ' + (remaining / 100));
    // Notify customer of remaining balance
  }
}

Daily Reconciliation Job

Webhooks can fail. Your server might miss a deposit notification. Run a daily reconciliation job that pulls all DVA transactions from Paystack and checks them against your records.

async function reconcileDVADeposits() {
  const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
  const today = new Date().toISOString().split('T')[0];

  // Fetch all successful transactions from yesterday
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(
      'https://api.paystack.co/transaction?from=' + yesterday +
      '&to=' + today +
      '&channel=dedicated_nuban' +
      '&status=success' +
      '&perPage=200' +
      '&page=' + page,
      {
        headers: {
          Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        },
      }
    );

    const data = await response.json();

    for (const tx of data.data) {
      // Check if we already processed this deposit
      const existing = await db.query(
        'SELECT id FROM processed_deposits WHERE paystack_reference = $1',
        [tx.reference]
      );

      if (existing.rows.length === 0) {
        // Missed deposit! Process it now.
        console.log('Found unprocessed DVA deposit: ' + tx.reference + ', amount: ' + (tx.amount / 100));
        const customer = await db.query(
          'SELECT id FROM customers WHERE paystack_customer_code = $1',
          [tx.customer.customer_code]
        );

        if (customer.rows.length > 0) {
          await processDeposit(customer.rows[0].id, tx.amount, tx.reference);
          await db.query(
            'INSERT INTO processed_deposits (paystack_reference, processed_at) VALUES ($1, $2)',
            [tx.reference, new Date()]
          );
        }
      }
    }

    if (page >= data.meta.pageCount) {
      hasMore = false;
    } else {
      page++;
    }
  }
}

This job is your safety net. It catches deposits that were successful on Paystack but never processed in your system. Run it every morning and alert your team if it finds unprocessed deposits.

Handling Multiple Deposits and Accumulation

Some use cases involve customers making multiple deposits to the same DVA over time. For example:

  • Wallet top-ups: The customer deposits money whenever they want. Each deposit adds to their wallet balance.
  • Installment payments: The customer pays part of an invoice today and the rest next week.
  • Savings products: The customer makes regular deposits toward a savings goal.

For each of these, your system needs to accumulate deposits correctly:

// Track all deposits for a customer
async function recordDeposit(customerId, amountInKobo, reference) {
  // Record the individual deposit
  await db.query(
    'INSERT INTO deposits (customer_id, amount, paystack_reference, created_at) VALUES ($1, $2, $3, $4)',
    [customerId, amountInKobo, reference, new Date()]
  );

  // Update the running total
  await db.query(
    'UPDATE customers SET total_deposited = total_deposited + $1, wallet_balance = wallet_balance + $1 WHERE id = $2',
    [amountInKobo, customerId]
  );
}

Keep a deposits table that records every individual deposit. This is your audit trail. If a customer disputes their balance, you can show them every deposit they made, with amounts, dates, and Paystack references. The wallet_balance field is computed from the deposits minus any withdrawals or purchases.

For applications with high deposit volumes, ensure your webhook handler and reconciliation job are idempotent. Use the Paystack reference as a unique constraint on your deposits table to prevent double-processing.

Debugging DVA Reconciliation Issues

Common DVA reconciliation problems and how to investigate them:

Customer says they transferred but no deposit recorded: Check the Paystack dashboard for the DVA. Look for the transaction in the transaction list. If Paystack shows the deposit, the issue is on your side (missed webhook). Run the reconciliation job or manually process the deposit. If Paystack does not show the deposit, the customer's bank transfer may still be processing or may have failed.

Deposit matched to the wrong customer: Verify your customer-to-DVA mapping. Pull the transaction from Paystack and check the customer_code. Cross-reference it with your database. If the mapping is wrong, fix the customer record and reprocess the deposit.

Amount mismatch between Paystack and your records: Paystack reports amounts in kobo. Verify you are dividing by 100 correctly. Also check for currency mismatches. If the customer deposited in a different currency than expected, the amount conversion may cause discrepancies.

Duplicate deposits processed: Check your idempotency logic. Every deposit should be recorded with its Paystack reference as a unique key. If two webhook deliveries for the same deposit arrive (Paystack retries), your handler should detect the duplicate reference and skip processing.

For the full accept payments workflow, see the complete accept payments guide. For DVA setup details, see Dedicated Virtual Accounts implementation.

Stay Up to Date

Paystack continues to improve their DVA product with new features and bank partnerships. We update these guides as the API evolves.

Join the McTaba newsletter for Paystack engineering updates.

Sign up for the McTaba newsletter

Key Takeaways

  • Each DVA is permanently linked to a specific customer. When a deposit arrives, you know which customer it belongs to because the DVA maps to their record in your system.
  • Deposits to DVAs trigger charge.success webhooks. The webhook payload includes the DVA details, the deposited amount, and the customer information. Use this data to credit the customer in your system.
  • Amounts may not always match what you expected. A customer might transfer more or less than the required amount. Your system needs rules for handling overpayment (credit the excess as balance, refund it, or apply to next invoice) and underpayment (reject, accept partial, or request the remainder).
  • Run a daily reconciliation job that queries Paystack for all DVA transactions and compares them against your internal records. This catches deposits where the webhook was missed or not processed.
  • Store the DVA account number and bank name in your customer record. This makes it easy to look up which customer a deposit belongs to when debugging or reconciling manually.
  • DVA deposits settle to your bank account on the normal settlement cycle, not instantly. The customer transfers to the DVA, Paystack processes it, and settlement happens on the next cycle.

Frequently Asked Questions

How quickly do DVA deposits appear on Paystack after the customer transfers?
Most bank transfers to DVAs are processed within seconds to a few minutes. The exact timing depends on the sending bank and the receiving bank (the one Paystack partners with for DVAs). Paystack fires the webhook as soon as the deposit is confirmed. During bank downtime or high-volume periods, there may be delays.
Can a customer deposit any amount to their DVA?
Yes. Unlike Initialize Transaction where you set the amount, DVA deposits are customer-initiated bank transfers. The customer can send any amount they choose. Your application must handle varying amounts and decide what to do with overpayments and underpayments based on your business rules.
What happens if someone who is not the account owner deposits to a DVA?
The deposit goes through regardless of who initiated the transfer. DVAs identify the receiving account, not the sender. If you need to know who sent the money, the bank transfer details may include the sender account name, but this is not always reliable. For most use cases, you credit the DVA owner (your customer) regardless of who sent the money.
How do I handle DVA deposits for customers who have been deactivated?
If a customer is deactivated in your system but their DVA is still active, deposits will still arrive. You have two options: deactivate the DVA through the Paystack API to prevent future deposits, or keep the DVA active and credit the deposit to a holding account for manual review. The safest approach is to deactivate the DVA when you deactivate the customer.
Are DVA deposits subject to the same fees as card payments?
DVA deposit fees may differ from card payment fees. Bank transfer fees are typically structured differently from card processing fees. Check your Paystack dashboard for the specific fee structure that applies to DVA deposits on your account.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon