e-invoicing2026-07-08

Debugging LHDN MyInvois API Errors Without Guessing

A code-grounded guide to GetPay's MyInvois authentication, UBL 2.1 payload construction, submission safeguards, status reads, and error evidence.

GetPay Engineering Team
Updated: 2026-07-28

TL;DR (Key Takeaways)

  • Read the failing operation, HTTP status, and bounded response text before changing a payload; GetPay does not invent or hard-code a universal MyInvois error-code table.
  • GetPay stringifies the UBL document once, hashes those exact UTF-8 bytes with SHA-256, and Base64-encodes the same string.
  • Token and document-status operations are retried because they are safe reads or token mints; document submission and state changes are timeout-bounded but never automatically retried.
  • Normal B2C invoices may fall back to the Malaysian general-public TIN, while the foreign general TIN is used only for an eligible foreign individual supplier in GetPay's self-billed flow.

Start with the operation, not a guessed error code

MyInvois failures are easiest to diagnose when the evidence is kept in order:

  1. Identify whether the failure occurred during token minting, document submission, a status read, or a cancellation/rejection state change.
  2. Keep the actual HTTP status and returned response text.
  3. Compare the exact UBL document that was hashed with the bytes that were Base64-encoded.
  4. Correct the local data or mapping rule only after the failed operation is clear.

GetPay deliberately does not translate every response into a made-up catalogue of names such as “invalid TIN” or “duplicate document.” Its native client preserves LHDN's response evidence with a length limit so logs remain useful without becoming unbounded.

Before changing or transmitting a document, run its JSON through GetPay's free MyInvois payload validator. It checks required fields, TIN syntax, and totals consistency without an account or an LHDN call.

GetPay operationHTTP method and pathRetry policyDiagnostic retained on failure
Access tokenPOST /connect/tokenRetry with backoffStatus and first 200 response characters
Submit documentsPOST /api/v1.0/documentsubmissionsTimeout only; no retryStatus and first 500 response characters
Read document detailsGET /api/v1.0/documents/{uuid}/detailsRetry with backoffStatus and first 300 response characters
Cancel or rejectPUT /api/v1.0/documents/state/{uuid}/stateTimeout only; no retryStatus and first 300 response characters

Authentication: honour expires_in

GetPay requests a client-credentials token with the InvoicingAPI scope. It does not assume that every token always lasts a fixed number of seconds. Instead, it uses the expires_in value returned by the token endpoint and caches the token in the server process.

The cached token is reused only when more than 60 seconds remain:

const cached = tokenCache.get(key)
if (cached && cached.exp > Date.now() + 60_000) return cached.token

tokenCache.set(key, {
  token: json.access_token,
  exp: Date.now() + json.expires_in * 1000,
})

Token minting is safe to retry with backoff because it does not submit an accounting document. If token acquisition fails, inspect the status and returned text before rotating credentials or changing scopes.

Build the hash and Base64 document from one string

buildSubmissionDocument() creates one JSON string and derives both transport fields from it:

const canonical = JSON.stringify(ublDoc)
const bytes = new TextEncoder().encode(canonical)
const hashBuf = await crypto.subtle.digest("SHA-256", bytes)
const hashHex = [...new Uint8Array(hashBuf)]
  .map((byte) => byte.toString(16).padStart(2, "0"))
  .join("")
const document = Buffer.from(canonical, "utf8").toString("base64")

The practical rule is simple: never stringify once for the hash and again for the Base64 value after modifying the object. A single byte difference means the digest no longer describes the transmitted document.

The submission envelope contains:

  • format: "JSON";
  • the Base64 document;
  • the SHA-256 hexadecimal documentHash; and
  • the tenant-side invoice number as codeNumber.

A successful response is expected to contain a submissionUid, accepted documents with their uuid, and rejected documents with the error object returned by LHDN. GetPay does not fabricate a rejected-document code when the service has not supplied one.

Check the UBL mapper before blaming the network

For an ordinary invoice, GetPay's mapper currently emits:

  • document type 01, list version 1.1;
  • currency MYR;
  • supplier TIN and business registration identification;
  • buyer TIN, falling back to EI00000000010 for an unregistered Malaysian B2C buyer; and
  • line amounts whose tax allocation must foot to the document tax total.

The foreign general TIN EI00000000030 is not a universal buyer fallback. In GetPay it is used by the self-billed individual-supplier mapper when the supplier is outside Malaysia and has no supplied Malaysian TIN. That flow also validates identification type, country, address, phone number, MSIC, postal code, positive line values, and sen precision before building a type 11 self-billed document.

Sen-accurate tax allocation

Line tax is apportioned with the Hamilton largest-remainder method. Each positive line receives a non-negative number of cents, and the allocations add back to the rounded document tax:

const exact = weights.map((weight) => (totalCents * weight) / totalWeight)
exact.forEach((share, index) => {
  cents[index] = Math.floor(share)
})
let remainder = totalCents - cents.reduce((sum, value) => sum + value, 0)

This matters because independently rounding every line can over-allocate a small tax total and force the last line negative. Fixing the arithmetic before submission is safer than interpreting the resulting validation response after the fact.

Treat submission timeouts as an unknown outcome

GetPay calls the document-submission endpoint with fetchWithTimeout, not the retrying helper. The local submit flow first makes an atomic database claim by setting the invoice's LHDN state to SUBMITTING, which prevents concurrent callers. That claim does not prove what happened at LHDN if the network response is lost.

After a timeout:

  • do not blindly send the same document again;
  • preserve the local submission state and the exact payload evidence;
  • if a UUID was received, use the document-details endpoint to read its status; and
  • if no UUID was received, reconcile through the operational submission record instead of inventing a successful or failed outcome.

The same conservative rule applies to cancellation and rejection state changes: they are bounded by a timeout but not automatically replayed.

Read lifecycle data exactly as returned

GetPay's document-details type recognises Submitted, Valid, Invalid, and Cancelled. Validation details may include steps, each with a name, status, and optional error code and message. The application maps these values to its local PENDING, VALID, INVALID, or CANCELLED states and throws on an unknown status instead of silently guessing.

Issuer cancellation is implemented as a state update with a reason within the documented 72-hour window. Buyer rejection uses the same endpoint with a different state value. Both operations should be investigated from their real response evidence when they fail.

Diagnostic checklist

  • Confirm the tenant's environment, client ID, and client secret were loaded from that tenant's company record.
  • Identify the failing operation and keep its real HTTP status and bounded response text.
  • Verify that the SHA-256 digest and Base64 document came from the same JSON string.
  • Check document type, version, TIN role, registration scheme, MSIC, address, phone, currency, and sen precision against the mapper for that flow.
  • Confirm line tax adds exactly to document tax.
  • Never treat a missing network response as proof that a mutating request failed.

Frequently Asked Questions

Why does GetPay not automatically retry a timed-out document submission?

The LHDN gateway may have accepted the submission even when its response never reached GetPay. Sending the same POST again could file a second document. GetPay therefore combines an atomic local SUBMITTING claim with a timeout-bounded, no-retry submission request.

Does GetPay use the same fallback TIN for every buyer and supplier?

No. A normal Malaysian B2C invoice may use the general-public TIN EI00000000010 when the buyer has no TIN. EI00000000030 appears in the self-billed individual-supplier mapper only when an eligible foreign supplier has no Malaysian TIN.

What error details does GetPay preserve from MyInvois?

For a non-success response, GetPay records the failing operation and HTTP status together with a bounded portion of the response body. A successful submission is typed as acceptedDocuments and rejectedDocuments; each rejected document may carry the code and message returned by LHDN.

Sources & Ground Truth

Direct LHDN MyInvois Submission Engine

Ready to automate your Malaysian e-invoicing & bookkeeping?

GetPay handles 100% compliant e-invoices, multi-bank reconciliation, and statutory payroll out of the box.

Get Started Free

Related Articles