npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@weiliang79/myinvois-client

v0.1.1

Published

HTTP client for Malaysia's MyInvois (LHDN) e-invoicing API — auth, token cache, endpoints

Readme

@weiliang79/myinvois-client

npm CI node

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-client

Requires 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.12

On 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 call

Submitting 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 codeNumber out of the document. It must equal the document's own cbc:ID; a mismatch corrupts reconciliation rather than failing loudly, because duplicate detection compares type, version, id, issue date and supplier TIN together. Pass codeNumber only 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(), not getXml(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 (&#34;, not &quot;); 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 untouched

Two 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 &amp; first turns the escaped text &amp;lt;br&amp;gt;, which stands for the literal characters &lt;br&gt;, 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 (DS300DS338) 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 issuer

Cancelling 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);  // 4

These 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 credentials

fetch 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 InvoiceDirection on recent and invoiceDirection on search. 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.
  • recent rows carry totalSales / netAmount / total; search rows carry totalExcludingTax / totalNetAmount / totalPayableAmount. List rows use issuerTIN / receiverID; /raw and /details use issuerTin / receiverId.

Licence

MIT