Integrating with LHDN MyInvois API: What Malaysian Developers Need to Know
Malaysia's e-invoicing mandate under LHDN (Lembaga Hasil Dalam Negeri) is rolling out in phases, and if you're building finance or ERP software for Malaysian businesses, integrating with the MyInvois API is no longer optional. This post covers everything I learned while implementing it in production — from OAuth2 setup to UBL document quirks and the gotchas that cost me days.
1. What is MyInvois and Why It Matters
MyInvois is Malaysia's national e-invoicing platform, operated by LHDN. Starting August 2024 for large taxpayers (and rolling down to smaller businesses through 2025–2026), all B2B and B2C invoices must be submitted to LHDN in real time for validation before they can be considered legally valid.
Key things to understand upfront:
- Every invoice must be submitted to MyInvois and receive a UUID + QR code from LHDN before sharing with your customer.
- Documents are structured in UBL 2.1 format (XML or JSON).
- There is a sandbox environment at
preprod.myinvois.hasil.gov.myfor development. - LHDN provides both a taxpayer portal and a middleware API — you'll be using the middleware API if you're integrating directly.
The official docs are at the LHDN developer portal. Read them. Then read them again. Then come back here for the parts they gloss over.
2. Authentication: OAuth2 Client Credentials
MyInvois uses OAuth2 with the client credentials grant type. There are two identity providers depending on who is authenticating:
- Taxpayer (direct integration): Uses MyTax identity via
https://myaccount.hasil.gov.my - ERP / Intermediary: Uses a separate client ID/secret issued by LHDN for intermediary systems
For intermediary/ERP integrations (the most common case), the token endpoint is:
POST https://preprod.myinvois.hasil.gov.my/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=InvoicingAPI
A successful response looks like:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "InvoicingAPI"
}
In Laravel, I wrapped this in a service class with token caching:
class MyInvoisAuthService
{
public function getAccessToken(): string
{
return Cache::remember('myinvois_token', 3500, function () {
$response = Http::asForm()->post(config('myinvois.token_url'), [
'grant_type' => 'client_credentials',
'client_id' => config('myinvois.client_id'),
'client_secret' => config('myinvois.client_secret'),
'scope' => 'InvoicingAPI',
]);
throw_unless($response->successful(), new MyInvoisException(
'Token fetch failed: ' . $response->body()
));
return $response->json('access_token');
});
}
}
Cache for slightly under the expires_in value (I use 3500s for a 3600s token) to avoid race conditions on expiry.
Gotcha: On-Behalf-Of (Intermediary Acting for Taxpayer)
If you're an intermediary submitting on behalf of a taxpayer, you need to pass the taxpayer's TIN in the request header:
// All API requests on behalf of a taxpayer must include:
'onbehalfof' => 'C12345678901' // Taxpayer's TIN
Miss this header and you'll get a confusing 400 error with no useful message.
3. Submitting an Invoice
The submission endpoint accepts a batch of documents (up to 100 per request). Each document is a Base64-encoded UBL JSON or XML string, plus a SHA-256 hash.
POST https://preprod.myinvois.hasil.gov.my/api/v1.0/documentsubmissions
Authorization: Bearer {token}
Content-Type: application/json
{
"documents": [
{
"format": "JSON",
"document": "{base64-encoded UBL document}",
"documentHash": "{sha256 of the raw document string}",
"codeNumber": "INV-2024-001"
}
]
}
UBL Document Structure
The UBL document is a JSON object following the UBL 2.1 Invoice schema, with LHDN-specific extensions. The minimum required fields for a valid invoice:
{
"_D": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"_A": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
"_B": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
"Invoice": [{
"ID": [{"_": "INV-2024-001"}],
"IssueDate": [{"_": "2024-08-01"}],
"IssueTime": [{"_": "10:30:00Z"}],
"InvoiceTypeCode": [{"_": "01", "listVersionID": "1.0"}],
"DocumentCurrencyCode": [{"_": "MYR"}],
"TaxCurrencyCode": [{"_": "MYR"}],
"InvoicePeriod": [{
"StartDate": [{"_": "2024-08-01"}],
"EndDate": [{"_": "2024-08-31"}],
"Description": [{"_": "Monthly"}]
}],
"AccountingSupplierParty": [{ /* supplier details */ }],
"AccountingCustomerParty": [{ /* buyer details */ }],
"TaxTotal": [{ /* tax summary */ }],
"LegalMonetaryTotal": [{ /* totals */ }],
"InvoiceLine": [{ /* line items */ }]
}]
}
The namespace prefixes (_D, _A, _B) are LHDN's compact JSON representation of UBL XML namespaces. Every field value is wrapped in an array of objects with a _ key — this trips up almost everyone on first encounter.
Hashing and Encoding
$documentJson = json_encode($ublDocument, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$hash = hash('sha256', $documentJson);
$encoded = base64_encode($documentJson);
// Then include in the submission payload:
[
'format' => 'JSON',
'document' => $encoded,
'documentHash' => $hash,
'codeNumber' => 'INV-2024-001',
]
The hash must be computed on the exact string you're encoding. Any difference in whitespace or character escaping between the string you hash and the string you Base64-encode will result in a hash mismatch rejection.
4. Validation and Status Polling
Submission is asynchronous. The API returns immediately with a submissionUID and a per-document documentStatus. Documents start in Submitted state and transition to either Valid or Invalid after LHDN validation (usually within seconds, but can take longer under load).
// Poll submission status
GET /api/v1.0/documentsubmissions/{submissionUID}
// Or check a single document
GET /api/v1.0/documents/{documentUUID}/details
A validated document response includes:
{
"uuid": "ABCD-1234-...",
"submissionUID": "...",
"longId": "HJSD...", // used to construct the QR URL
"internalId": "INV-2024-001",
"typeName": "Invoice",
"typeVersionName": "1.0",
"issuerTin": "C12345678901",
"issuerName": "Acme Sdn Bhd",
"receiverTin": "...",
"receiverName": "...",
"dateTimeIssued": "2024-08-01T10:30:00Z",
"dateTimeReceived": "2024-08-01T10:30:05Z",
"dateTimeValidated": "2024-08-01T10:30:07Z",
"status": "Valid",
"documentStatusReason": null
}
The QR code URL is constructed as:
https://myinvois.hasil.gov.my/{uuid}/share/{longId}
This URL must appear on the printed invoice. I store both uuid and longId in the database after a successful validation.
Handling Invalid Documents
When a document is Invalid, the documentStatusReason field contains a JSON array of validation errors. These are structured but not always human-friendly. Build a proper error logger — you will need it.
if ($status === 'Invalid') {
Log::error('MyInvois rejection', [
'invoice' => $invoice->id,
'uuid' => $doc['uuid'],
'reasons' => $doc['documentStatusReason'],
]);
// Surface the error to the user
throw new MyInvoisValidationException($doc['documentStatusReason']);
}
5. Lessons Learned / Production Gotchas
These are the things that weren't in the docs — or were buried in a footnote I missed:
- The sandbox TINs are specific. You can't use made-up TINs in preprod. LHDN provides a set of test TINs in their developer portal. Use them or your submissions will fail TIN validation.
-
IssueTime must be in UTC (
Zsuffix). Submitting+08:00offset caused rejections in some document types. Always convert to UTC before submitting. -
Decimal precision matters. Line item amounts, tax amounts, and totals must agree to 2 decimal places. A rounding difference of 0.01 between
TaxableAmountand the sum of line taxes will fail validation. -
The
codeNumberfield must be unique per submission. If you retry a failed submission with the samecodeNumber, LHDN may reject it as a duplicate. Use the invoice number + a retry suffix strategy. - Rate limits exist but are undocumented. In production we hit rate limit errors during month-end batch processing. Add exponential backoff with jitter to your retry logic.
- Cancellation has a 72-hour window. Once an invoice is validated, you can only cancel it within 72 hours. After that, you must issue a credit note. Design your UI to surface this clearly.
- Test certificate pinning in preprod. The preprod SSL certificate is different from production. If you're doing any certificate validation in your HTTP client, test in preprod thoroughly before cutting over.
MyInvois is a well-designed system overall, but like any government API it has edges that only reveal themselves under real-world conditions. The UBL format is verbose but consistent once you wrap your head around the JSON namespace encoding. Build yourself a solid abstraction layer, cache your tokens, log everything, and you'll be in good shape.
Questions or corrections? Reach out via the contact section of my portfolio.