DEV Community

Afeez Muibi
Afeez Muibi

Posted on

How I Wired LHDN's e-Invoice Mandate Into a Multi-Tenant ERP — Without Trusting Just One Path to Do It Alone

I was recently deep in Urusentra, the construction ERP I built solo for Malaysian SMEs, when LHDN's e-Invoice mandate stopped being a "later" problem and became a "now" one. Every business on the platform would eventually need to submit invoices, credit notes, debit notes, and refunds to LHDN's MyInvois system, get them validated asynchronously, and live inside a strict document lifecycle where the wrong move means a real company either fails compliance or double-reports a transaction to a tax authority.

That last part is what made this interesting to me. This isn't a feature where a bug means a bad UI moment. It's a feature where a bug means someone's tax filing is wrong, so it is one serious task that took time and care for me to get it done.

I was responsible for the whole thing which are the OAuth layer, the payload construction, the submission and polling lifecycle, TIN validation, and the Celery orchestration tying it together. This article is about how I designed that, the constraints LHDN quietly forces on you, and the one design decision I keep coming back to as the right call.

The Real Problem Wasn't "Call an API"

On paper, e-Invoice integration sounds like plumbing: build a JSON payload, POST it, done. In practice, LHDN hands you four separate constraints that don't show up until you're actually building against their spec, and each one shapes the architecture differently.

The payload isn't JSON, it's UBL 2.1 wearing a JSON costume. Every single leaf value has to be wrapped — {"_": value} instead of a bare value, and the document is a bunch of single-item arrays nested inside single-item arrays. It's not hard to get right once. It's very easy to get right for the Invoice model and quietly wrong for Credit Note model three weeks later, because nobody is re-checking the wrapping discipline on the second document type.

Multi-tenancy means the boring parts multiplied by every company on the platform. Each business has its own MyInvois Client ID and secret, its own environment setting (Sandbox while they're testing, Production once they've onboarded), and its own submission history. A single global "the token" doesn't exist here, there's a token per company per environment, and if I got that scoping wrong even once, one tenant's submission could get signed with another tenant's credentials, which will be apocalyptic to say the least.

LHDN validates asynchronously, and punishes one for asking too eagerly. A submission doesn't come back "valid" or "invalid", it comes back as 202 Accepted, and the actual validation happens on LHDN's side over the following seconds or minutes. You're expected to poll for it. But LHDN is explicit in their docs that hammering their taxpayer-validation endpoint on every submission will get you flagged as abusive traffic. So the system has to be patient in exactly the ways I'd normally want it to be eager.

Everything downstream of an Invoice has to prove its lineage. A Credit Note doesn't just reference "an invoice" the way it does in my own database, it has to reference the LHDN-assigned UUID of that invoice, which only exists once LHDN has validated it. Try to submit a Credit Note against an Invoice that hasn't cleared validation yet, and you're stuck.

None of these are exotic problems individually. Together, they meant I couldn't treat "submit to LHDN" as one function. It had to be five cooperating pieces, each with one job, none of them trusting the others to have already done theirs. Pretty complex, isn't it?

Token Handling: Designing for the 5 Minutes I Don't Control

The first thing I built was the auth layer, because everything else depends on it, and I wanted to get the boring-but-critical part right before touching anything interesting.

LHDN tokens live for 60 minutes. My first instinct was to cache for the full hour and refresh on 401. I changed my mind almost immediately, a token that is seconds from expiring when a request goes out is a race I didn't want to have to debug at 2am for some tenant if I were to be on vacation at a different timezone. So I cache for 55 minutes instead, a deliberate 5-minute buffer:

cache_seconds = min(expires_in - 300, 3300)  # 3600(1hr) - 300(5mins), 3300(55mins)
cache.set(cache_key, token, timeout=cache_seconds)
Enter fullscreen mode Exit fullscreen mode

The cache key itself is scoped per company and per environment:

def _token_cache_key(company):
    return f"lhdn_token_{company.pk}_{company.einvoice_environment}"
Enter fullscreen mode Exit fullscreen mode

That last part matters more than it looks. Without the environment in the key, a company's Sandbox token and Production token would collide in cache, and a business could accidentally submit real invoices while still "testing." I only caught this while writing the key function itself, not while testing, which made me go back and double check every other cache key in the module for the same blind spot.

LHDN also issues two client secrets per business, presumably for rotation or redundancy. I didn't want a support conversation that starts with "why did my invoices stop submitting," so if the primary secret comes back invalid_client, the auth service quietly retries with the secondary before surfacing any failure to the user:

if error == 'invalid_client' and company.myinvois_client_secret_2:
    logger.warning(f"[e-Invoice Auth] Secret 1 failed for company {company.pk}. Trying Secret 2.")
    return _fetch_token_with_secret_2(company, token_url, cache_key)
Enter fullscreen mode Exit fullscreen mode

And on any 401 from any downstream call, submission, polling, cancellation, TIN validation, the token gets evicted immediately rather than waiting for the cache to naturally expire:

def invalidate_token(company):
    cache_key = _token_cache_key(company)
    cache.delete(cache_key)
Enter fullscreen mode Exit fullscreen mode

Small function. But it's the difference between "the next call gets a fresh token" and "every call for the next 55 minutes fails the same way."

The Payload Builder: Treating the Spec as Non-Negotiable

This is the part I spent the most actual thinking time on, because it's where sloppiness hides the longest.

My first pass, if I'm honest with myself, would have been four separate build_invoice_payload, build_credit_note_payload, build_debit_note_payload, build_refund_payload functions, each hand-rolling the UBL structure from scratch. I could feel, even sketching it out, that this was how the {"_": value} wrapping rule would eventually drift — get it right on Invoice, forget a wrapper on Debit Note six weeks later when I'm moving fast, and now LHDN silently rejects a company's debit note with an error message that doesn't obviously point back to the missing wrapper.

So instead I built the shared pieces first, build_supplier_party, build_buyer_party, build_tax_total, build_invoice_lines, and had every document type assemble itself from those:

def build_invoice_payload(invoice):
    ...
    payload = {
        "_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": [{
            "AccountingSupplierParty": [build_supplier_party(company)],
            "AccountingCustomerParty": [build_buyer_party(customer)],
            "TaxTotal": build_tax_total(invoice, lines),
            "InvoiceLine": build_invoice_lines(lines, currency_code),
            ...
        }]
    }
Enter fullscreen mode Exit fullscreen mode

Credit Notes and Debit Notes reuse build_supplier_party and build_buyer_party completely untouched, the supplier and buyer don't change shape depending on what kind of document you're issuing, so there was no reason to let them drift independently. Where the document types genuinely differ, a Refund line has no quantity or unit price, it's just a flat amount against a payment type, I let them diverge, but only there.

The lineage requirement I mentioned earlier, Credit/Debit/Refund Notes needing the LHDN UUID of the original Invoice, not my own foreign key, became a hard gate before any payload gets built at all:

if not original_invoice.lhdn_uuid:
    raise EInvoicePayloadError(
        f"The original Invoice {original_invoice.formatted_invoice_number()} "
        f"has not been submitted to LHDN yet (no lhdn_uuid). "
        f"Submit the Invoice first before submitting a Credit Note against it."
    )
Enter fullscreen mode Exit fullscreen mode

For a Refund, that chain is three links deep, refund → credit_note → related_payment (Invoice), and I walk and validate the whole thing before letting a single dict get built. I'd rather fail loud and early with a message that tells someone exactly what's missing than let a malformed payload travel all the way to LHDN and come back with a cryptic rejection which will put me in trouble.

The Envelope: Minify, Hash, Encode, Wrap

Once the payload exists, LHDN doesn't want it raw. They want it minified, SHA256-hashed for integrity, base64-encoded, and wrapped in a submission envelope with one's own internal reference number attached:

def wrap_for_submission(payload_dict, code_number):
    minified = json.dumps(payload_dict, separators=(',', ':'))
    encoded_bytes = minified.encode('utf-8')
    doc_hash = hashlib.sha256(encoded_bytes).hexdigest()
    doc_base64 = base64.b64encode(encoded_bytes).decode('utf-8')

    return {
        "documents": [{
            "format": "JSON",
            "document": doc_base64,
            "documentHash": doc_hash,
            "codeNumber": code_number,
        }]
    }
Enter fullscreen mode Exit fullscreen mode

This is a small function, but I made a point of it being the only place in the codebase that does this transformation. All four prepare_*_submission() functions funnel through it. If LHDN ever changes the envelope format, or I find a bug in the hashing, there's exactly one function to fix, not four copies that have each drifted slightly since the day I wrote them.

TIN Validation: Respecting a Rule I Didn't Write

LHDN's documentation has a line that I kept coming back to while designing this part: don't call the TIN validation endpoint before every submission, or you'll get flagged as abusive traffic. That's an unusual constraint to design around, most APIs want you to validate defensively, as often as you can. This one explicitly doesn't.

So TIN validation isn't part of the submission path at all. It's a separate gate that trusts a cached boolean on the customer record, and only reaches out to LHDN when that trust hasn't been established yet:

def check_tin_valid_before_submission(customer, company):
    if customer.tin_validated:
        logger.debug(f"Customer {customer.customer_number} TIN already validated. Skipping API call.")
        return True

    return validate_buyer_tin(customer, company)
Enter fullscreen mode Exit fullscreen mode

The validation itself runs once, when a customer's TIN is first added or changed, not on some polling schedule, not "just in case" before a submission. That decision moved an expensive, rate-limited external call out of the hot path entirely, which meant I could stop thinking about LHDN's rate limits every time I thought about invoice submission, and only think about them in the one place they actually apply.

Polling: Patience, But Isolated Patience

Because LHDN validates asynchronously, something has to check back in. My first sketch of this was a single periodic task which was, wake up every 60 seconds, loop through every pending submission, poll each one in sequence. It's the obvious design, and it's also the one where a single stuck submission, a timeout, a stale token, could stall or delay every other tenant's polling behind it in the same run.

I didn't like that shape once I pictured it under real load, so I split it into two tasks instead: a lightweight dispatcher, and an isolated, individually-retryable worker per submission.

@shared_task(name='sales.tasks.poll_all_pending_submissions')
def poll_all_pending_submissions():
    pending = EInvoiceSubmission.objects.filter(status='Submitted').values_list('pk', flat=True)
    for submission_id in pending:
        poll_single_submission.delay(submission_id)


@shared_task(bind=True, max_retries=5, default_retry_delay=30)
def poll_single_submission(self, submission_id):
    ...
    try:
        return poll_submission_status(submission)
    except Exception as e:
        try:
            raise self.retry(exc=e)
        except self.MaxRetriesExceededError:
            logger.error(f"Max retries exceeded for submission {submission_id}.")
Enter fullscreen mode Exit fullscreen mode

Now a bad submission for Company A retries in its own isolated task, up to 5 times with backoff, and never once touches Company B's polling in the same 60-second window. At scale, that isolation is the difference between "one tenant's flaky network blips slow down the whole platform" and "one tenant's flaky network blips are that tenant's problem, quietly retried, invisible to everyone else."

One Handler for Every Way LHDN Can Say No

A submission response can mean a lot of different things — clean acceptance, partial rejection inside a batch, a duplicate, a rate limit, an expired token — and every one of those outcomes has to update two records at once: the internal EInvoiceSubmission model which is the audit trail, and the actual source document, since that's what the rest of the ERP reads to decide what an invoice's status is.

I could have let submit_invoice, submit_credit_note, submit_debit_note, and submit_refund each handle their own response interpretation. I chose not to, mostly because I could already picture the failure mode: one of those four handles the 429 case slightly differently than the others because I fixed a bug in one and forgot to port it to its siblings. So all four route through a single shared handler instead:

return _handle_submission_response(
    response=response,
    submission=submission,
    source_document=invoice,
    company=company,
    code_number=invoice.formatted_invoice_number(),
)
Enter fullscreen mode Exit fullscreen mode

_handle_submission_response is the only place in the codebase that knows what a 202-with-mixed-results means, the only place that calls invalidate_token on a 401, the only place that writes einvoice_status back onto a source document. Four document types, one interpretation of what LHDN's response actually means.

What This Taught Me

Constraints I didn't choose ended up shaping the best parts of the design. LHDN's "don't over-call TIN validation" warning could have been a note or point I worked around. Instead it forced a cleaner separation between validation and submission than I would have designed on my own, and now that separation is just how the system works, constraint or not.

The bug I almost shipped wasn't in the hard part. I spent the most deliberate thought on the UBL payload structure, because it looked the hardest. The near-miss, Sandbox and Production tokens colliding in cache was in a two-line function I wrote quickly because it looked trivial. I've started treating "this part is obviously simple" as its own small warning sign, not a reason to skip double-checking it.

Isolating failure is worth the extra task. The dispatcher-plus-isolated-worker pattern for polling took more code than one big loop would have. It's also the reason a flaky network moment for one tenant has never once shown up as a problem for another. That trade felt obvious once I pictured the alternative running for real companies, with real invoices, at real scale.

This is one piece of a larger system I've built solo for Urusentra, some are... multi-tenant document sequencing, bank reconciliation, job costing, and now e-Invoice compliance, all on the same Django/DRF and React stack. If asynchronous compliance integrations, multi-tenant design, or the LHDN spec specifically are things you've wrestled with too, I'd genuinely like to compare notes in the comments.

Top comments (0)