Logo
HomeAbout MeProjectsBlogsMemoriesContact
HomeAbout MeProjectsBlogsMemoriesContact

Integrating Viettel eContract API in a Legacy Java EE System

A deep-dive into building a production e-contract signing integration on top of JBoss/JEE (Java 7, no Spring DI) — covering the parts that actually matter: authentication lifecycle, token caching, and handling async callbacks.

API Reference: Viettel vContract Integration API Specification v1.0.12

1. Overview

The system communicates with Viettel vContract in two directions:

  • Outbound — Internal system actively calls Viettel APIs (create contract, trigger signing flow, download signed files...)
  • Inbound — Viettel pushes callbacks into Internal system whenever a contract's status changes

2. Authentication & Token Management (The Core Part)

2.1. Why Token Caching Matters

Viettel issues a token with a TTL of ~10 days (observed: 863,700,000 ms from production logs). Calling /api/auth/login on every request would be both slow and wasteful. The system uses AbstractTokenCacheService to cache and reuse tokens across all API calls.

2.2. Real Logs From Production Test

[INFO]  Token refreshed for provider [VIETTEL]. Valid for 863700000 ms
[DEBUG] Token cache HIT for provider [VIETTEL]. Remaining=863698508ms
  • HIT — token still valid, reused as-is, no network call
  • MISS / refresh — token expired or 401 received → re-login once, then retry the original request

2.3. Service Layer Structure

TokenService (interface)
    ├── getToken()              → returns current token (cached or freshly fetched)
    ├── refreshToken()          → forces cache invalidation + re-login (called on 401)
    └── buildLoginRequestBody() → constructs JSON body for /api/auth/login

AbstractTokenCacheService (base implementation)
    ├── cache: Map<provider, {token, expireAt}>
    ├── getToken()              → checks cache → calls login if needed
    └── refreshToken()          → clears cache → calls getToken()

ViettelAuthServiceImpl
    └── extends AbstractTokenCacheService
        └── provider = "VIETTEL"

3. Outbound API Calls

3.1. Class Structure

3.2. The Standard Call Pattern — Auto Refresh on 401

Every API call goes through callWithAutoRefresh, which guarantees exactly one retry on 401:

3.3. Response Decoding

Viettel wraps every JSON response as base64-encoded plain text:

Raw HTTP body:  "eyJjb2RlIjoiT0siLCJtZXNzYWdlIjoiT0sifQ=="
                        ↓ Base64 decode
Actual JSON:    {"code":"OK","message":"OK","success":true,"data":{...}}

Exception: The file download API (III.9) returns a raw binary ZIP stream on success — this must be read with apiBinary() (raw byte[]), never through a BufferedReader or InputStreamReader, which would corrupt the binary data due to UTF-8 decoding.

4. End-to-End Flow (From Checker Approval to Signed File)

Important: createContract returning {"code":"OK"} only means Viettel accepted the request into its queue — it does not mean the contract was created. Always wait for the DONE_START_FLOW callback or poll getRequestResult before proceeding.

5. Inbound Callback Handling

The handler class uses a no-arg constructor (required for the framework to instantiate via reflection from config) and directly instantiates its dependencies:

public class ViettelContractCallbackHandler {

    // no-arg constructor — framework instantiates via reflection
    public ViettelContractCallbackHandler() {}

    private final ViettelContractResultServiceImpl resultService
            = new ViettelContractResultServiceImpl();

    private final ViettelDownloadServiceImpl downloadService
            = new ViettelDownloadServiceImpl();
}

6. Lessons Learned & Gotchas

#ProblemSolution
1createContract returns OK but contract doesn't exist on Viettel's sideAlways wait for callback DONE_START_FLOW or poll getRequestResult — never proceed immediately
2Reading the file download response through BufferedReader corrupts the ZIPUse apiBinary() to read raw byte[]; extract with ZipInputStream
3Token expiring mid-request causes 401callWithAutoRefresh handles exactly one retry after refreshToken()
4PartnerDTO field assignment bug causes silent failuresA typo (partnerDTO.set... instead of partnerDTO1.set...) caused createContract to return OK but the contract was never created — ECT_VIETTEL_API_LOG is essential to catch this
5All JSON responses are base64-encodedAlways call decodeBase64Body() before parsing — raw body is not valid JSON
6No-arg constructor required for callback handlerFramework uses reflection to instantiate from config — constructor injection is not possible; use direct instantiation instead
© 2026 thisisduykhanh. All rights reserved.