T-VAN Integration
Overview
T-VAN (Tax Value Added Network) providers act as intermediaries between businesses and the General Department of Taxation (GDT). They receive signed e-invoices, validate them, and transmit to the tax authority.
Provider reality (code-verified): the service wires T-VAN through the
@nx/t-vanadapter with theVNPAYprovider (TVAN_DEFAULT_CONNECTION), alongside the@nx/iiapilayer (VNIS + VNPAY). See Integration for the authoritative provider/adapter wiring. The provider table below is reference background on the broader T-VAN ecosystem.
T-VAN Providers
| Provider | API Type | Status |
|---|---|---|
| VNPT | REST/SOAP | Supported |
| Viettel | REST | Supported |
| BKAV | REST | Supported |
| FPT | REST | Supported |
| MISA | REST | Planned |
Integration Flow
T-VAN Request/Response
Submit Invoice
http
POST /api/v1/invoices/submit
Content-Type: application/json
Authorization: Bearer <api_key>
{
"merchantId": "merchant-123",
"invoiceXml": "<HDon>...signed XML...</HDon>",
"invoiceNumber": "C25TAA/00000001",
"issueDate": "2025-01-20"
}Response
json
{
"success": true,
"data": {
"submissionId": "sub-abc-123",
"status": "SUBMITTED",
"timestamp": "2025-01-20T10:00:00Z",
"estimatedProcessingTime": 300
}
}Query Status
http
GET /api/v1/invoices/status/{submissionId}
Authorization: Bearer <api_key>json
{
"submissionId": "sub-abc-123",
"status": "AUTHORIZED",
"cqtCode": "ABC123DEF456",
"processedAt": "2025-01-20T10:05:00Z"
}Webhook Callbacks
Webhook Payload
typescript
interface TVanWebhook {
event: 'INVOICE_AUTHORIZED' | 'INVOICE_REJECTED';
timestamp: string;
data: {
submissionId: string;
invoiceNumber: string;
cqtCode?: string; // For AUTHORIZED
rejectionCode?: string; // For REJECTED
rejectionReason?: string;
};
signature: string; // HMAC signature for verification
}Webhook Verification
typescript
function verifyWebhook(payload: string, signature: string): boolean {
const expectedSignature = crypto
.createHmac('sha256', process.env.TVAN_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}Webhook Handler
typescript
app.post('/webhooks/tvan', async (c) => {
const payload = await c.req.text();
const signature = c.req.header('X-Webhook-Signature');
// Verify signature
if (!verifyWebhook(payload, signature)) {
return c.json({ error: 'Invalid signature' }, 401);
}
const webhook: TVanWebhook = JSON.parse(payload);
switch (webhook.event) {
case 'INVOICE_AUTHORIZED':
await invoiceService.markAuthorized({
submissionId: webhook.data.submissionId,
cqtCode: webhook.data.cqtCode,
});
break;
case 'INVOICE_REJECTED':
await invoiceService.markRejected({
submissionId: webhook.data.submissionId,
reason: webhook.data.rejectionReason,
});
break;
}
return c.json({ received: true });
});TVanIntegrationService
typescript
@injectable()
class TVanIntegrationService {
constructor(
private httpClient: HttpClient,
private config: TVanConfig,
) {}
/**
* Submit signed invoice to T-VAN
*/
async submitInvoice(opts: {
merchantId: string;
signedXml: string;
invoiceNumber: string;
issueDate: Date;
}): Promise<SubmissionResult> {
const response = await this.httpClient.post(
`${this.config.apiUrl}/invoices/submit`,
{
merchantId: opts.merchantId,
invoiceXml: opts.signedXml,
invoiceNumber: opts.invoiceNumber,
issueDate: opts.issueDate.toISOString(),
},
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return {
submissionId: response.data.submissionId,
status: response.data.status,
};
}
/**
* Query submission status
*/
async queryStatus(submissionId: string): Promise<SubmissionStatus> {
const response = await this.httpClient.get(
`${this.config.apiUrl}/invoices/status/${submissionId}`,
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return {
status: response.data.status,
cqtCode: response.data.cqtCode,
rejectionReason: response.data.rejectionReason,
};
}
/**
* Cancel submitted invoice
*/
async cancelInvoice(opts: {
submissionId: string;
reason: string;
}): Promise<CancelResult> {
const response = await this.httpClient.post(
`${this.config.apiUrl}/invoices/cancel`,
{
submissionId: opts.submissionId,
reason: opts.reason,
},
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return response.data;
}
}Response Codes
T-VAN Response Codes
| Code | Description | Action |
|---|---|---|
| 00 | Success | Invoice accepted |
| 01 | Invalid signature | Re-sign and retry |
| 02 | Invalid XML format | Fix XML and retry |
| 03 | Duplicate invoice number | Use new number |
| 04 | Invalid tax code | Verify tax codes |
| 05 | Certificate expired | Use valid certificate |
| 06 | Invalid template | Register template first |
| 07 | Seller not registered | Complete registration |
| 08 | Service unavailable | Retry later |
GDT Response Codes
| Code | Description |
|---|---|
| 1 | Approved - CQT code issued |
| 2 | Rejected - Invalid data |
| 3 | Rejected - Duplicate invoice |
| 4 | Rejected - Template mismatch |
| 5 | Pending review |
Retry Mechanism
Retry Configuration
typescript
const retryConfig = {
maxRetries: 3,
initialDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
backoffMultiplier: 2,
retryableErrors: [
'NETWORK_ERROR',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'SERVICE_UNAVAILABLE',
],
nonRetryableErrors: [
'INVALID_SIGNATURE',
'INVALID_XML',
'DUPLICATE_INVOICE',
'CERTIFICATE_EXPIRED',
],
};Retry Implementation
typescript
async function submitWithRetry(
invoice: Invoice,
config: RetryConfig,
): Promise<SubmissionResult> {
let lastError: Error;
let delay = config.initialDelay;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await tvanService.submitInvoice(invoice);
} catch (error) {
lastError = error;
// Check if error is retryable
if (config.nonRetryableErrors.includes(error.code)) {
throw error;
}
if (attempt === config.maxRetries) {
break;
}
// Wait before retry
await sleep(delay);
delay = Math.min(delay * config.backoffMultiplier, config.maxDelay);
}
}
throw new MaxRetriesExceededError(
`Failed after ${config.maxRetries} attempts`,
lastError,
);
}Polling for Status
For providers without webhook support:
typescript
async function pollForStatus(
submissionId: string,
timeout: number = 300000, // 5 minutes
): Promise<SubmissionStatus> {
const startTime = Date.now();
let interval = 5000; // Start with 5 seconds
while (Date.now() - startTime < timeout) {
const status = await tvanService.queryStatus(submissionId);
if (status.status === 'AUTHORIZED' || status.status === 'REJECTED') {
return status;
}
await sleep(interval);
interval = Math.min(interval * 1.5, 30000); // Max 30 seconds
}
throw new TimeoutError('Status polling timed out');
}Provider-Specific Configuration
VNPT T-VAN
typescript
const vnptConfig: TVanConfig = {
provider: 'VNPT',
apiUrl: 'https://api.vnpt-invoice.com.vn/v1',
apiKey: process.env.VNPT_API_KEY,
secretKey: process.env.VNPT_SECRET_KEY,
webhookSecret: process.env.VNPT_WEBHOOK_SECRET,
};Viettel T-VAN
typescript
const viettelConfig: TVanConfig = {
provider: 'VIETTEL',
apiUrl: 'https://api.sinvoice.viettel.vn/v1',
apiKey: process.env.VIETTEL_API_KEY,
secretKey: process.env.VIETTEL_SECRET_KEY,
webhookSecret: process.env.VIETTEL_WEBHOOK_SECRET,
};Environment Variables
| Variable | Description |
|---|---|
TVAN_PROVIDER | Provider name (VNPT, VIETTEL, BKAV, FPT) |
TVAN_API_URL | Provider API endpoint |
TVAN_API_KEY | API authentication key |
TVAN_SECRET_KEY | Secret key for signing |
TVAN_WEBHOOK_SECRET | Webhook signature verification |
TVAN_TIMEOUT | Request timeout (ms) |
Error Handling
typescript
class TVanError extends Error {
constructor(
message: string,
public code: string,
public submissionId?: string,
public retryable: boolean = false,
) {
super(message);
this.name = 'TVanError';
}
}
// Usage
try {
await tvanService.submitInvoice(invoice);
} catch (error) {
if (error instanceof TVanError) {
if (error.retryable) {
await scheduleRetry(invoice, error);
} else {
await markFailed(invoice, error);
}
}
throw error;
}