Blog · Engineering

EFRIS Response Decoding: Base64, GZIP, and AES in the Right Order

· February 24, 2026 · 6 min read

Language-agnostic guide to extracting multi-layer encoded responses from URA EFRIS APIs.

EFRIS API responses are wrapped in multiple encoding layers. Decode them wrong, pass the wrong format to your crypto function, or get the order wrong — and you’ll get binary garbage or JSON parse errors. Here’s the correct pattern.

The Envelope

Every EFRIS response wraps its payload in the same structure:

{
  "returnStateInfo": {
    "returnCode": "00",
    "returnMessage": "SUCCESS"
  },
  "data": {
    "content": "H4sIAAAAAAAAA6tWKkktLlGyUlA...",
    "dataDescription": {
      "codeType": "1",
      "encryptCode": "2",
      "zipCode": "1"
    }
  }
}

The content field is always Base64-encoded. But what’s inside that Base64 depends entirely on the three metadata flags in dataDescription:

FlagValueMeaning
zipCode"1"Content is GZIP-compressed
codeType"1"Content is encrypted
encryptCode"2"Encryption uses AES-128-ECB

These flags determine how many layers you need to peel off. In the worst case (T115 System Dictionary), you need all three.

The Four Scenarios

1. Plaintext (Health Check, T101)

zipCode: "0", codeType: "0"

Decoding: Base64 → UTF-8 → JSON ✓

Example:
  Response: H4sIAAAAA...  (base64 of plaintext)
  After decode: {"name": "System"}
  Result: Parse and use directly

2. Encrypted Only (TIN Query, T119)

zipCode: "0", codeType: "1", encryptCode: "2"

Decoding: Base64 → AES-decrypt → JSON ✓

Example:
  Response: a7d43d26ba5b0f6c43...  (base64 of encrypted bytes)
  After AES decrypt: {"tin": "123456", ...}
  Result: Parse and use directly

3. Compressed Only (Some Reference Data)

zipCode: "1", codeType: "0"

Decoding: Base64 → GZIP-decompress → JSON ✓

Example:
  Response: H4sIAAAAA...  (base64 of gzip stream)
  After decompress: {"items": [...], ...}
  Result: Parse and use directly

4. Compressed AND Encrypted (System Dictionary, T115)

zipCode: "1", codeType: "1", encryptCode: "2"

Decoding: Base64 → GZIP-decompress → AES-decrypt → JSON ✓

CRITICAL: Decompression happens BEFORE decryption. 
The data was compressed first, then encrypted.

Example:
  Response: H4sIAA...  (base64 of gzipped+encrypted bytes)
  After decompress: <encrypted binary>
  After AES decrypt: {"currencyType": [...], ...}
  Result: Parse and use directly

Common Mistake

The most common error is passing the wrong format to your AES decrypt function:

WRONG ❌

base64_content = base64_decode(responseContent)
decrypted = decrypt_aes(convert_to_utf8(base64_content), sessionKey)

CORRECT ✅

base64_content = base64_decode(responseContent)
if (needs_decompression):
    base64_content = decompress_gzip(base64_content)
decrypted = decrypt_aes(convert_to_base64(base64_content), sessionKey)

The decrypt_aes() function expects Base64 input, not UTF-8. Passing UTF-8 where Base64 is expected produces garbled output — no error, just wrong bytes.

The Correct Pipeline

After the fix, the full T115 extraction works like this:

Pseudocode:

function extract_response(responseContent, dataDescription, sessionKey):
    // 1. Base64 decode
    buffer = base64_decode(responseContent)
    // buffer: [1F 8B 08 00 ...] — GZIP magic bytes

    // 2. Decompress if needed
    if (dataDescription.zipCode == "1" OR first_two_bytes(buffer) == 0x1F 0x8B):
        buffer = gzip_decompress(buffer)
    // buffer: [A7 D4 3D 26 ...] — encrypted binary

    // 3. Decrypt if needed
    if (dataDescription.codeType == "1" AND dataDescription.encryptCode == "2"):
        // CRITICAL: re-encode buffer as base64 BEFORE decrypting
        base64_string = buffer_to_base64(buffer)
        json_string = aes_128_ecb_decrypt(base64_string, sessionKey)
    else:
        json_string = buffer_to_utf8(buffer)
    
    // 4. Parse
    data = json_parse(json_string)
    return data

Key points:

  • Decompression happens before decryption
  • The encryption function expects Base64 input, not raw bytes
  • Check both metadata AND magic bytes for compression

The Shared Function

We had this logic duplicated in two places — the generic tester and the sync handler — and they diverged. One passed Base64, the other passed UTF-8. Classic copy-paste drift.

We extracted a single shared function:

Pseudocode:

function extract_response_content(
    content: string,              // Base64-encoded response content
    dataDescription: object,      // Response metadata (zipCode, codeType, etc.)
    sessionKey: string            // AES decryption key (Base64)
) -> string:
    // Step 0: Base64 decode
    buffer = base64_decode(content)

    // Step 1: Decompress (check BOTH metadata AND magic bytes)
    isCompressed = (dataDescription.zipCode == "1")
    hasGzipHeader = (buffer[0] == 0x1F AND buffer[1] == 0x8B)
    
    if (isCompressed OR hasGzipHeader):
        buffer = gzip_decompress(buffer)

    // Step 2: Decrypt
    isEncrypted = (dataDescription.codeType == "1")
    
    if (isEncrypted AND dataDescription.encryptCode == "2"):
        // Convert buffer to Base64 for decryption
        base64_buffer = buffer_to_base64(buffer)
        return aes_128_ecb_decrypt(base64_buffer, sessionKey)
    else:
        return buffer_to_utf8(buffer)

Now every endpoint — sync, tester, future integrations — calls the same function. One place to fix, one place to test.

How to Debug When It Breaks

If you’re staring at garbled output, add this single debug statement after each step:

print_hex(first_10_bytes(buffer))

The first two bytes tell you everything:

First BytesHexMeaningNext Step
{"7B 22Raw JSONParse directly
[5B ...JSON arrayParse directly
GZIP1F 8BGZIP streamDecompress first
OtherRandomEncrypted binaryDecrypt via Base64

If you see 1F 8B after decompressing: Something went wrong with decompression. Re-check the decompression library call.

If you see random bytes after decryption: Your session key is wrong, expired, or you passed UTF-8 instead of Base64 to the decryption function.

If decryption throws an error about “block length”: You’re probably passing a UTF-8 string where Base64 is expected.

Key Takeaways

  1. Order is critical: Decompress BEFORE decrypt. Never the reverse.

  2. Format matters: Your AES decrypt function expects Base64. Don’t convert to UTF-8 first.

  3. Check both metadata AND magic bytes: Use dataDescription.zipCode and codeType, but also verify GZIP magic bytes (0x1F 0x8B) since metadata can be missing.

  4. Create a shared function: Don’t duplicate this logic across endpoints. Put it in one place, test it once.

  5. Debug with hex: Print byte values in hex, not as UTF-8 strings. Garbled characters tell you nothing; hex bytes tell you exactly what’s happening (GZIP, encrypted data, or JSON).

  6. Check your crypto library docs: Different languages treat base64/raw bytes differently. Verify what your AES implementation expects.

Implement this pattern once in your language of choice, and you won’t hit this problem again.