Bonaventure OgetoBy Bonaventure Ogeto|

Handling dedicatedaccount.assign Events

The dedicatedaccount.assign.success webhook event fires when Paystack successfully assigns a dedicated virtual account (DVA) to a customer. The payload contains the assigned bank name, account number, and account name. Your handler should store these details, display the account number to the customer, and prepare to receive charge.success events when deposits land in the virtual account.

What Are Dedicated Virtual Accounts

A Dedicated Virtual Account (DVA) is a permanent bank account number that Paystack assigns to one of your customers. When anyone sends money to that account number via bank transfer, the funds land in your Paystack balance and a charge.success webhook fires with the deposit details.

This is different from the one-time virtual accounts that Paystack generates during a regular bank transfer payment. Those temporary accounts expire after a few hours. A DVA is permanent. The customer can use the same account number for every payment, indefinitely.

DVAs are popular in Nigeria because bank transfers are the most common payment method. Customers already know how to transfer money. Giving them a fixed account number removes the friction of going through a checkout page every time. In practical terms, it works like giving each customer their own "deposit account" that maps back to your platform.

The typical use cases:

  • Wallet top-ups. Fintech apps where customers load money into their wallet by transferring to their personal account number.
  • Recurring payments. Customers who pay monthly can transfer to the same account number each time without going through checkout.
  • Marketplace deposits. Sellers on a marketplace receive their own virtual account for buyer payments.

This article is part of the Paystack webhooks engineering guide.

The DVA Creation Flow

Creating a DVA is a multi-step process that involves two webhooks. Here is the full flow:

  1. Create a Paystack customer (or use an existing one). Every customer has a customer_code.
  2. Verify the customer's identity by calling the Customer Identification API with their BVN (required in Nigeria). This is asynchronous.
  3. Receive customeridentification.success via webhook. See handling customer identification events.
  4. Create the dedicated account by calling the Paystack Create Dedicated Account API with the customer code and your preferred bank.
  5. Receive dedicatedaccount.assign.success via webhook. This is the event this article covers.
  6. Store and display the account details to the customer.

The API call to create the dedicated account looks like this:

const https = require('https');

function createDedicatedAccount(customerCode) {
  const data = JSON.stringify({
    customer: customerCode,
    preferred_bank: 'wema-bank', // or 'titan-paystack'
  });

  const options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/dedicated_account',
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
      'Content-Length': data.length,
    },
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', (chunk) => { body += chunk; });
      res.on('end', () => {
        const parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });
    req.on('error', reject);
    req.write(data);
    req.end();
  });
}

The API call returns immediately with a pending status. The actual account assignment happens asynchronously, and the result arrives through the dedicatedaccount.assign.success webhook. This can take anywhere from a few seconds to a couple of minutes.

The dedicatedaccount.assign.success Payload

When Paystack successfully assigns a virtual account, the webhook payload looks like this:

{
  "event": "dedicatedaccount.assign.success",
  "data": {
    "customer": {
      "id": 12345678,
      "first_name": "Amina",
      "last_name": "Okafor",
      "email": "amina@example.com",
      "customer_code": "CUS_abc123def456",
      "phone": "+2348012345678",
      "risk_action": "default"
    },
    "dedicated_account": {
      "bank": {
        "name": "Wema Bank",
        "id": 20,
        "slug": "wema-bank"
      },
      "account_name": "PAYSTACK-AMINA OKAFOR",
      "account_number": "7801234567",
      "assigned": true,
      "currency": "NGN",
      "active": true,
      "id": 987654,
      "created_at": "2026-07-20T10:30:00.000Z",
      "updated_at": "2026-07-20T10:30:05.000Z"
    },
    "identification": {
      "country": "NG",
      "type": "bvn",
      "value": "123****789"
    }
  }
}

The fields that matter for your integration:

  • dedicated_account.account_number - The 10-digit NUBAN that the customer will transfer money to. This is the most important field.
  • dedicated_account.account_name - The name that will show when someone does a name inquiry before transferring. Usually prefixed with "PAYSTACK-".
  • dedicated_account.bank.name - The bank the account is domiciled in (e.g., Wema Bank).
  • customer.customer_code - Links the account back to your customer.
  • dedicated_account.active - Should be true for a newly assigned account.

The account_number and bank.name are what you display to your customer. They will use these details to make bank transfers from their personal bank app or USSD.

Handling the Assignment Event

Your handler should store the account details and make them available to the customer immediately:

async function handleDedicatedAccountAssign(data) {
  const { customer, dedicated_account } = data;

  if (!dedicated_account.assigned || !dedicated_account.active) {
    console.error(
      'DVA assignment event received but account is not active: ' +
      customer.customer_code
    );
    return;
  }

  // Store the virtual account details
  await db.query(
    'INSERT INTO virtual_accounts (' +
    '  customer_code, account_number, account_name, ' +
    '  bank_name, bank_slug, currency, paystack_dva_id, ' +
    '  is_active, created_at' +
    ') VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ' +
    'ON CONFLICT (customer_code) DO UPDATE SET ' +
    '  account_number = $2, account_name = $3, ' +
    '  bank_name = $4, bank_slug = $5, is_active = $8',
    [
      customer.customer_code,
      dedicated_account.account_number,
      dedicated_account.account_name,
      dedicated_account.bank.name,
      dedicated_account.bank.slug,
      dedicated_account.currency,
      dedicated_account.id,
      true,
    ]
  );

  // Update the DVA request status if you are tracking the flow
  await db.query(
    'UPDATE dva_requests SET status = $1, updated_at = NOW() ' +
    'WHERE customer_code = $2 AND status = $3',
    ['dva_active', customer.customer_code, 'dva_requested']
  );

  // Notify the customer with their new account details
  await emailQueue.add('dva-assigned', {
    to: customer.email,
    accountNumber: dedicated_account.account_number,
    accountName: dedicated_account.account_name,
    bankName: dedicated_account.bank.name,
  });

  // If the customer is currently on your site waiting,
  // push a real-time update via WebSocket or SSE
  await realtimeQueue.add('dva-ready', {
    customerCode: customer.customer_code,
    accountNumber: dedicated_account.account_number,
    bankName: dedicated_account.bank.name,
  });
}

The ON CONFLICT clause handles the case where Paystack retries the webhook. If you already stored the account details from a previous delivery, the upsert just updates them without creating a duplicate row.

The real-time notification (last part of the handler) is important for user experience. If the customer is on your site waiting for their account number, they should not have to refresh the page. Push the details to them the moment the webhook arrives.

Handling Deposits After Assignment

Once the DVA is assigned, deposits do not trigger another dedicatedaccount event. Instead, each deposit triggers a regular charge.success webhook. The payload looks like a normal charge, but the channel field will be "dedicated_nuban" and the authorization object will contain the virtual account details.

{
  "event": "charge.success",
  "data": {
    "id": 98765432,
    "domain": "live",
    "status": "success",
    "reference": "DVA_ref_abc123",
    "amount": 1000000,
    "currency": "NGN",
    "channel": "dedicated_nuban",
    "customer": {
      "email": "amina@example.com",
      "customer_code": "CUS_abc123def456"
    },
    "authorization": {
      "authorization_code": "AUTH_nuban001",
      "account_name": "JOHN DOE",
      "sender_bank": "First Bank",
      "sender_bank_account_number": "0123456789",
      "sender_name": "JOHN DOE"
    },
    "metadata": {}
  }
}

Key fields for DVA deposits:

  • channel - Will be "dedicated_nuban" for DVA deposits, not "card" or "bank".
  • authorization.sender_name - The name of the person who made the transfer. This may not match your customer (anyone can transfer to the account number).
  • authorization.sender_bank - The bank the transfer came from.
  • authorization.sender_bank_account_number - The sender's account number.

In your charge.success handler, route DVA deposits differently from card or regular bank transfer payments:

async function handleChargeSuccess(data) {
  const { channel, customer, amount, reference } = data;

  if (channel === 'dedicated_nuban') {
    // This is a DVA deposit. Credit the customer's wallet.
    await creditWallet(customer.customer_code, amount, reference);
  } else {
    // Regular payment. Fulfill the order.
    await fulfillOrder(reference, amount);
  }
}

One important thing: anyone with the account number can transfer money to it. If a customer shares their virtual account number with a friend, the friend's transfer will still credit the customer's account on your platform. This is usually the desired behavior for wallet top-ups, but think about whether it fits your use case.

Error Scenarios and Edge Cases

DVA assignment is not always smooth. Here are the scenarios you should handle:

Assignment fails silently. Sometimes the API call succeeds (returns 200) but the webhook never arrives. This can happen if the bank rejects the account creation for reasons not caught in the API response. Set a timeout: if you do not receive dedicatedaccount.assign.success within 10 minutes of making the API call, alert your operations team and consider retrying.

// When you create the DVA, also schedule a timeout check
await db.query(
  'INSERT INTO dva_requests (customer_code, status, requested_at) ' +
  'VALUES ($1, $2, NOW())',
  [customerCode, 'dva_requested']
);

// Schedule a check for 10 minutes later
await jobQueue.add(
  'check-dva-assignment',
  { customerCode: customerCode },
  { delay: 10 * 60 * 1000 }
);

// The check job:
async function checkDvaAssignment(customerCode) {
  const request = await db.query(
    'SELECT status FROM dva_requests WHERE customer_code = $1',
    [customerCode]
  );

  if (request.rows[0]?.status === 'dva_requested') {
    // Still pending after 10 minutes. Alert the team.
    await slackQueue.add('dva-timeout', {
      channel: '#operations',
      text: 'DVA assignment timed out for ' + customerCode +
        '. Check Paystack dashboard.',
    });
  }
}

Customer already has a DVA. If you call the Create Dedicated Account API for a customer who already has one, Paystack may return the existing account instead of creating a new one. Your handler should use ON CONFLICT (upsert) to handle this gracefully.

Account gets deactivated. Paystack can deactivate a DVA if there are compliance issues. You may receive a dedicatedaccount.assign.failed event in some cases. Monitor for this and notify both the customer and your operations team.

Multiple banks. Paystack can assign DVAs from different banks (Wema Bank, Titan by Paystack). If you request accounts from multiple banks for the same customer, you will receive separate webhook events for each. Store all of them and display the one that is most convenient for the customer's bank (to avoid inter-bank transfer fees).

Database Schema for DVA

Here is a practical schema for tracking virtual accounts and their lifecycle:

CREATE TABLE virtual_accounts (
  id SERIAL PRIMARY KEY,
  customer_code VARCHAR(50) NOT NULL,
  account_number VARCHAR(10) NOT NULL,
  account_name VARCHAR(200) NOT NULL,
  bank_name VARCHAR(100) NOT NULL,
  bank_slug VARCHAR(50) NOT NULL,
  currency VARCHAR(3) DEFAULT 'NGN',
  paystack_dva_id INTEGER,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(customer_code, bank_slug)
);

CREATE TABLE dva_requests (
  id SERIAL PRIMARY KEY,
  customer_code VARCHAR(50) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'awaiting_kyc',
  -- awaiting_kyc | kyc_complete | dva_requested | dva_active | failed
  requested_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for looking up accounts by customer
CREATE INDEX idx_virtual_accounts_customer ON virtual_accounts(customer_code);

-- Index for finding pending DVA requests
CREATE INDEX idx_dva_requests_status ON dva_requests(status) WHERE status != 'dva_active';

The UNIQUE(customer_code, bank_slug) constraint allows a customer to have accounts at multiple banks while preventing duplicates at the same bank. The partial index on dva_requests keeps queries fast when you are looking for stuck or pending requests.

Testing DVA Webhooks

DVA events can be tested in Paystack test mode. Use test customer codes and the test BVN numbers from Paystack's documentation to trigger the full flow.

For local testing, simulate the webhook with curl:

SECRET="sk_test_your_secret_key"
BODY='{"event":"dedicatedaccount.assign.success","data":{"customer":{"id":12345,"first_name":"Test","last_name":"User","email":"test@example.com","customer_code":"CUS_test123","phone":"+2348000000000"},"dedicated_account":{"bank":{"name":"Wema Bank","id":20,"slug":"wema-bank"},"account_name":"PAYSTACK-TEST USER","account_number":"7800000001","assigned":true,"currency":"NGN","active":true,"id":999,"created_at":"2026-07-20T10:30:00.000Z","updated_at":"2026-07-20T10:30:05.000Z"},"identification":{"country":"NG","type":"bvn","value":"123****789"}}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/paystack   -H "Content-Type: application/json"   -H "x-paystack-signature: $SIG"   -d "$BODY"

Then simulate a deposit to the DVA by sending a charge.success event with channel set to "dedicated_nuban".

For the full testing approach, see simulating Paystack webhook events for automated tests.

Key Takeaways

  • The dedicatedaccount.assign.success event fires when Paystack assigns a virtual bank account number to a customer. This happens after customer identification (KYC) is complete.
  • The payload includes the assigned bank name, account number, account name, and the customer code. Store all of these in your database.
  • DVAs are permanent accounts tied to a customer. Any deposit to the account number triggers a charge.success webhook, not another dedicatedaccount event.
  • Customer identification (BVN verification in Nigeria) is a prerequisite for DVA creation. Handle customeridentification.success before requesting a DVA.
  • Display the virtual account details to the customer immediately after receiving the assignment event. They can start sending money to it right away.
  • Always verify the webhook signature before storing or displaying account details. A forged event could show a customer someone else's account number.

Frequently Asked Questions

How long does it take for a dedicated virtual account to be assigned after the API call?
DVA assignment typically takes a few seconds to a couple of minutes. The webhook fires once the bank confirms the account creation. If you do not receive the webhook within 10 minutes, check the Paystack dashboard for the assignment status and consider retrying the API call.
Can a customer have multiple dedicated virtual accounts?
Yes. A customer can have DVAs at different banks (e.g., one at Wema Bank and one at Titan by Paystack). Each assignment triggers a separate dedicatedaccount.assign.success webhook. Store all of them and let the customer choose which one to use.
What happens when someone transfers money to a dedicated virtual account?
The deposit triggers a regular charge.success webhook event with the channel field set to "dedicated_nuban". The payload includes the sender name, sender bank, and amount. The funds land in your Paystack balance. You then credit the customer associated with that virtual account in your system.
Do I need BVN verification before creating a dedicated virtual account?
Yes, in Nigeria. Paystack requires customer identification (BVN verification) before creating a DVA. You must first call the Customer Identification API, wait for the customeridentification.success webhook, and only then call the Create Dedicated Account API. Without verification, the DVA creation will fail.
Can anyone transfer to a dedicated virtual account, or only the assigned customer?
Anyone with the account number can transfer money to it. The funds are attributed to the customer who owns the DVA, regardless of who sent the money. This is by design and works well for wallet top-ups, but consider the implications for your specific use case.

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