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:
createContractreturning{"code":"OK"}only means Viettel accepted the request into its queue — it does not mean the contract was created. Always wait for theDONE_START_FLOWcallback or pollgetRequestResultbefore 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
| # | Problem | Solution |
|---|---|---|
| 1 | createContract returns OK but contract doesn't exist on Viettel's side | Always wait for callback DONE_START_FLOW or poll getRequestResult — never proceed immediately |
| 2 | Reading the file download response through BufferedReader corrupts the ZIP | Use apiBinary() to read raw byte[]; extract with ZipInputStream |
| 3 | Token expiring mid-request causes 401 | callWithAutoRefresh handles exactly one retry after refreshToken() |
| 4 | PartnerDTO field assignment bug causes silent failures | A 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 |
| 5 | All JSON responses are base64-encoded | Always call decodeBase64Body() before parsing — raw body is not valid JSON |
| 6 | No-arg constructor required for callback handler | Framework uses reflection to instantiate from config — constructor injection is not possible; use direct instantiation instead |