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.
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:
This article is part of the Paystack webhooks engineering guide.
Creating a DVA is a multi-step process that involves two webhooks. Here is the full flow:
customer_code.customeridentification.success via webhook. See handling customer identification events.dedicatedaccount.assign.success via webhook. This is the event this article covers.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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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:
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.
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.
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:
"dedicated_nuban" for DVA deposits, not "card" or "bank".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.
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).
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.
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course