@weiliang79/myinvois-client
v0.1.1
Published
HTTP client for Malaysia's MyInvois (LHDN) e-invoicing API — auth, token cache, endpoints
Maintainers
Readme
@weiliang79/myinvois-client
An HTTP client for Malaysia's MyInvois (LHDN) API. It submits documents and reads their status; it builds none of
them. Document construction and XAdES signing live in @weiliang79/ubl-builder.
0.x. The endpoint surface is complete and most of it is verified against LHDN's preprod environment, but the public API may still change between minor versions. Pin an exact version if that matters to you.
Install
npm install @weiliang79/myinvois-clientRequires Node 22 or newer. There are no runtime dependencies — fetch is built in.
This package is ESM-only
It ships ES modules and has no CommonJS build. import works as you would expect.
CommonJS callers are not shut out. require() of an ES module landed unflagged in Node 22.12, so from a .cjs or
"type": "commonjs" file:
const { MyInvoisClient } = require('@weiliang79/myinvois-client'); // Node >= 22.12On 22.0–22.11 that throws ERR_REQUIRE_ESM; use await import(...) or upgrade. This is deliberately documented
rather than pinned in engines, because it is a constraint on CommonJS consumers rather than on the package.
Usage
clientId and clientSecret come from registering your ERP system in the MyInvois portal — not from your portal
login. Preprod and production issue separate credentials.
import { MyInvoisClient } from '@weiliang79/myinvois-client';
const client = new MyInvoisClient({
environment: 'preprod', // or 'production'
clientId: process.env.MYINVOIS_CLIENT_ID!,
clientSecret: process.env.MYINVOIS_CLIENT_SECRET!,
});
// Tokens are acquired, cached and refreshed for you.
const types = await client.documentTypes.list();Every endpoint is a typed method on the client — client.documents, client.taxpayer, client.notifications,
client.documentTypes. client.authorizedJson(path, options, onBehalfOf) is the escape hatch underneath them, for
anything LHDN adds before this package catches up.
Intermediary mode — submitting for a represented taxpayer — takes the TIN either per client or per call:
const client = new MyInvoisClient({ /* … */ onBehalfOf: 'C1234567890' });
await client.authorizedJson('/api/v1.0/documenttypes', {}, 'C0987654321'); // per callSubmitting documents
Build the envelope from the document ubl-builder produced, then submit up to 100 of them at once:
import { buildSubmissionDocument } from '@weiliang79/myinvois-client';
const xml = invoice.getXml(); // compact — see below
const result = await client.documents.submit([buildSubmissionDocument(xml)]);
result.submissionUid; // poll with this
result.acceptedDocuments; // [{ uuid, invoiceCodeNumber }]
result.rejectedDocuments; // [{ invoiceCodeNumber, error }]Submission is asynchronous. A 202 means received, not accepted, and a partly rejected batch is an ordinary
outcome rather than an error — submit resolves, and you read the split. The outcome of each document appears
through the submission:
const status = await client.documents.getSubmission(result.submissionUid, { pageNo: 1, pageSize: 100 });
status.overallStatus; // 'InProgress' | 'Valid' | 'Partially Valid' | 'Invalid'buildSubmissionDocument does three things you would otherwise have to remember:
- Hashes the document, not its base64. Both fields derive from one string through two independent transforms, and hashing the encoded form is a documented cause of rejection. The string is built once and used twice.
- Reads
codeNumberout of the document. It must equal the document's owncbc:ID; a mismatch corrupts reconciliation rather than failing loudly, because duplicate detection compares type, version, id, issue date and supplier TIN together. PasscodeNumberonly to assert what you expect — a disagreement is an error. - Refuses a pretty-printed signed document. LHDN recanonicalizes what it receives and counts whitespace between
elements as content, so an indented signed document is rejected while remaining schema-valid. With ubl-builder
that means
getXml(), notgetXml(true).
Batch limits (100 documents, 300KB per document, 5MB per submission) are checked before the request is made, so an
oversized batch costs nothing against the rate limit. buildSubmission re-runs every per-document check against the
encoded content — including verifying documentHash against the document it decodes — so the guarantees above hold
even for an envelope assembled by hand or replayed from storage.
Reading documents back
await client.documents.get(uuid); // metadata + the document itself
await client.documents.getDetails(uuid); // metadata + validationResults (no document)
await client.documents.recent({ direction: 'Sent', status: 'Valid', pageSize: 20 });
await client.documents.search({
searchQuery: 'INV-2026',
issueDateFrom: '2026-01-01T00:00:00Z', // a complete date range is required
issueDateTo: '2026-09-01T00:00:00Z',
pageNo: 1,
});get returns the document plainly — verified against preprod on 2026-09-04. The surface that does not is the
portal's Download button, which returns a <document> metadata record with the document XML-escaped inside an
inner <document> element, using numeric character references (", not "); fed to a UBL parser it
yields metadata with no invoice in it. For a portal file, or anything else handing back that record form:
import { unwrapRetrievedDocument } from '@weiliang79/myinvois-client';
const { document } = await client.documents.get(uuid);
const xml = unwrapRetrievedDocument(document); // a plain document is returned untouchedTwo things it gets right that are easy to get wrong. It finds the inner element structurally before decoding
anything, so escaped markup in a metadata field — documentStatusReason is free text from a cancellation — can
never be mistaken for the document. And it decodes every entity form in a single scan rather than a chain of
.replace calls: a sequential unescape re-reads its own output, so decoding & first turns the escaped text
&lt;br&gt;, which stands for the literal characters <br>, into a real element — corrupting
exactly the documents whose content mentions markup.
getDetails is what you read after a rejection. Take validationResults.validationSteps in order: signature
validation (DS300–DS338) runs before the taxpayer check (Error05), so a document with a valid signature and
the wrong TIN fails at a later step than one with a bad signature — the step it reached tells you which problem you
have.
recent covers the last 30 days; search goes further back and requires a complete date range — either
submissionDateFrom/To or issueDateFrom/To. Both page by pageNo/pageSize, with metadata.totalPages on
the response. Omitting the range is refused locally rather than costing a 400.
Three response shapes come back from these four calls, and they are typed separately because MyInvois genuinely
differs between them: /raw and /details return submissionUid with totalPayableAmount, while recent and
search rows return submissionUID with totalSales/netAmount/total. Only /raw carries the document.
Cancelling and rejecting
await client.documents.cancel(uuid, 'Wrong amount'); // issuer, within 72 hours of validation
await client.documents.reject(uuid, 'Not my purchase'); // recipient, a request to the issuerCancelling is the issuer's action and the window is 72 hours from validation; after it, the correction has to be a credit note. Rejecting is the recipient asking the issuer to cancel — it does not itself invalidate the document.
Both were verified against preprod on 2026-09-04. reject moves a document to Requested for Rejection — and works
even when you are both issuer and recipient — while cancel moves it to Cancelled.
Unlike submission, both are replayed after a transport fault: setting the same state twice reaches the same state.
Note the asymmetry that creates, though. A replayed cancel that already landed comes back 400 with
code: "ValidationError" and a nested IncorrectState — "The document is already cancelled." So cancel can
throw for a cancellation that succeeded; re-read the document's status before treating the throw as a failure.
Taxpayers, notifications and document types
await client.taxpayer.validateTin('C1234567890', { idType: 'BRN', idValue: '201901234567' }); // → boolean
await client.taxpayer.searchTin({ taxpayerName: 'ACME SDN BHD' });
await client.taxpayer.fromQrCode(scanned);
await client.notifications.list({ dateFrom, language: 'en' });
await client.documentTypes.list();
await client.documentTypes.getVersion(45, 41);validateTin returns a boolean rather than throwing. MyInvois answers "no" with a 404: the request succeeded and
the answer is negative, so making that an exception would leave the ordinary negative case indistinguishable from a
real fault. Genuine failures still throw.
Reading documentTypes beats hardcoding: the type version a document declares (listVersionID on
cbc:InvoiceTypeCode) has to be one MyInvois still accepts, and that set changes without the UBL schema moving.
The token cache
Caching is a requirement, not an optimisation: the login endpoint allows 12 requests per minute per client id, so an uncached client ceilings at twelve submissions a minute and spends its whole budget authenticating.
The default is in-memory and needs no configuration. To back it with Redis or similar, implement three methods:
interface TokenCache {
get(key: string): Promise<string | undefined>;
set(key: string, token: string, ttlMs: number): Promise<void>;
delete(key: string): Promise<void>;
}An implementation holds a live credential. Do not log the value, honour the TTL rather than treating it as
advisory, and prefer an encrypted store. Keys are already hashed, so a client id never reaches your store in
plaintext, and tokens are keyed per represented taxpayer — a cache that ignored onBehalfOf would submit under the
wrong identity without failing.
Errors
Every failure is a MyInvoisError. Non-2xx responses become the class for their status — BadRequestError,
UnauthorizedError, ForbiddenError, NotFoundError, RateLimitError, ServerError, NotImplementedError,
ServiceUnavailableError — so you can branch on the class instead of on a number.
MyInvois's error payload is parsed onto the error itself:
try {
await client.documents.submit(documents);
} catch (error) {
if (error instanceof HttpError) {
error.errorCode; // 'ValidationError' on an HTTP error, 'DS302' on a validation one
error.apiError?.errorMS; // the Malay message, where the shape carries one
error.correlationId; // quote this to LHDN support
firstErrorMessage(error.apiError); // the first readable message, at any depth
flattenApiErrors(error.apiError); // every nested error, outermost first
}
}flattenApiErrors matters more than it looks. The outer object is usually a step-level summary; the specific
failures hang off innerError, sometimes several levels down. Reading only the top level is how you end up with
"Invalid document" and no idea which field.
A rejected document is not an exception. submit resolves with rejectedDocuments, each carrying the same
error shape — the call succeeded, the document didn't.
What the codes mean
classifyErrorCode sorts a code into Signature, CoreField, Structure, Taxpayer, Duplicate, Standard or
Unknown. Be aware of the provenance, because Standard mixes two sources: some of its codes are published by LHDN
(BadArgument, NotFound, …) and some are not (ValidationError and IncorrectState, which a refused cancel
returns, appear in no documentation). The DS/CF validation codes are not documented as an enumerable list
either — that classification is observed, not contractual, and anything unrecognised returns Unknown rather than
being forced into a category.
VALIDATION_STEPS holds the steps as the service names them, and validationStepNumber reads the StepNN-
prefix off a name so you can compare how far two documents got. firstFailedStep finds the one that stopped a
document.
const { validationResults } = await client.documents.getDetails(uuid);
const failed = firstFailedStep(validationResults?.validationSteps);
failed?.name; // 'Step04-Code Field Validator'
validationStepNumber(failed?.name); // 4These names come from observed responses, not from the SDK — which matters, because the documented validator list
disagrees with the service for four of its eight entries (it places Code and Duplicate after Taxpayer and
References; the service runs them before) and carries no StepNN- prefix at all, so nothing written from the
documentation matches a real response. Steps 01–02 are not reported, and an unsigned document produces no signature
step. Where signature validation sits in the order is therefore unverified.
Retries
Transport faults and 429 are retried; 429 honours Retry-After, up to a bound (default 60s) beyond which the
RateLimitError is raised instead — nothing can abort a sleep, so an unbounded wait would hang the call.
Nothing else is retried. Validation failures are deterministic: retrying a rejected document produces the same
rejection, more slowly, against the same rate limit. 5xx is excluded for the same reason — LHDN's preprod returns
a deterministic SystemError for IG-prefix individual taxpayers, reproducible with their own published sample.
A transport fault is only retried when replaying the request is safe. A reset while reading a response is
indistinguishable from one before the request arrived, so POST is not replayed by default — a retried submission
would submit the batch twice. GET, HEAD, PUT, DELETE and OPTIONS are. Override per request with
idempotent: true where the service deduplicates for you. A 429 is replayed whatever the method, because it was
rejected before it was processed.
Diagnostics
A submission rejected for a TIN mismatch is almost always an ERP registered while the portal was in a personal rather than a business profile. The token says which TIN is actually bound:
import { boundTin } from '@weiliang79/myinvois-client';
boundTin(await client.auth.getToken()); // → 'C1234567890'A 403 on login raises SandboxAccessError, whose message points at the portal registration rather than at the
request — it is the most common sandbox pitfall and is rarely a fault in your code.
Testing
npm test # offline, no network
npm run test:live # opt-in; skipped entirely without credentialsfetch is injected through the constructor, which is what makes the offline suite possible at all. But an offline
suite tests recorded fixtures written from the same assumptions as the code, so it proves the client is consistent
with itself, not with LHDN — a gap that has produced real defects here more than once. That is what the live
suite is for.
The live suite is read-only. Every call in it is a GET: document types, recent, search, taxpayer lookup,
notifications, and one deliberate 404. It creates no documents, changes no state, and can be run as often as it is
useful. Submission and state changes are not in it.
Put credentials in .env (see .env.example) or export them; an exported variable wins. MYINVOIS_TEST_TIN,
_ID_TYPE and _ID_VALUE are optional and enable the positive TIN checks — without them only the negative case
runs, which is still the one that matters, since it is what proves a non-match arrives as a 404 rather than as
something else. MYINVOIS_QR_CODE enables the QR lookup.
Responses are saved to test/live/observed/ (gitignored — they hold real TINs, addresses and amounts). That is the
point of the suite as much as the assertions are: a body nobody has seen becomes one somebody has, and can be
replayed as an offline fixture so a live discovery becomes a permanent regression test.
What is verified, and what is not
Being explicit, because green tests otherwise imply coverage that does not exist:
| Area | Status |
|---|---|
| Auth, token cache, all read endpoints | Verified against preprod |
| Unsigned v1.0 submission, retrieval, validation | Verified against preprod |
| Taxpayer TIN validation and search, QR lookup | Verified against preprod |
| Cancel / reject | Verified against preprod, including the replayed-cancel behaviour |
| Intermediary mode (onBehalfOf) | Unverified for now — see below |
| Signed v1.1 submission | Unverified for now — see below |
Intermediary mode and signed v1.1 are unverified for now. The credentials this package was developed against are taxpayer-only, and a signed document needs a certificate from an MCMC-licensed CA — self-signed is rejected in the sandbox exactly as in production. Both paths are implemented and unit-tested; neither has been exercised against LHDN. The live suite skips them by name rather than omitting them, so their absence is visible in the output rather than something you have to infer.
If you use either and something does not work, please raise an issue with the response you got. One real response body settles more than any amount of reasoning from the documentation — and it becomes a permanent fixture, so the next person does not hit it.
Types follow the service, not the SDK
Where LHDN's published SDK and the running service disagree, these types follow what the service actually sends, confirmed by the live suite. If you compare against the SDK and think something here is wrong, check this table first:
| The SDK documents | The service sends |
|---|---|
| submissionUID on recent/search rows | submissionUid — consistently, on every endpoint |
| invoiceTypeCode as a Number | the string "01" — a numeric type destroys the leading zero |
| versionNumber as a Decimal | the string "1.0" |
| validators named Structure, Core Fields, Signature, Taxpayer, … | Step03-Duplicated Submission Validator, Step04-Code Field Validator, … — and Code and Duplicate run before Taxpayer and References |
| one error shape, using errorCode / error / innerError | two shapes. HTTP-level errors use code / message / details; only document-validation errors use the documented one. firstErrorCode and flattenApiErrors read either |
And these are the service's own inconsistencies rather than documentation errors, so expect them:
- The QR lookup is under
/api/v1.0/taxpayers/(plural); TIN validate and search are under/taxpayer/(singular). - The sent/received filter is
InvoiceDirectiononrecentandinvoiceDirectiononsearch. An unrecognised query parameter is ignored rather than rejected, and no direction filter means both directions — so the wrong spelling silently widens your result set instead of failing. recentrows carrytotalSales/netAmount/total;searchrows carrytotalExcludingTax/totalNetAmount/totalPayableAmount. List rows useissuerTIN/receiverID;/rawand/detailsuseissuerTin/receiverId.
Licence
MIT
