Skip to content

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-van adapter with the VNPAY provider (TVAN_DEFAULT_CONNECTION), alongside the @nx/iiapi layer (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

ProviderAPI TypeStatus
VNPTREST/SOAPSupported
ViettelRESTSupported
BKAVRESTSupported
FPTRESTSupported
MISARESTPlanned

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

CodeDescriptionAction
00SuccessInvoice accepted
01Invalid signatureRe-sign and retry
02Invalid XML formatFix XML and retry
03Duplicate invoice numberUse new number
04Invalid tax codeVerify tax codes
05Certificate expiredUse valid certificate
06Invalid templateRegister template first
07Seller not registeredComplete registration
08Service unavailableRetry later

GDT Response Codes

CodeDescription
1Approved - CQT code issued
2Rejected - Invalid data
3Rejected - Duplicate invoice
4Rejected - Template mismatch
5Pending 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

VariableDescription
TVAN_PROVIDERProvider name (VNPT, VIETTEL, BKAV, FPT)
TVAN_API_URLProvider API endpoint
TVAN_API_KEYAPI authentication key
TVAN_SECRET_KEYSecret key for signing
TVAN_WEBHOOK_SECRETWebhook signature verification
TVAN_TIMEOUTRequest 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;
}

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.