DEV Community

Sayed Rashid
Sayed Rashid

Posted on

Singapore's e-invoicing provider registry ships as a PDF. Here's how I parse it.

If you are building anything against Singapore's InvoiceNow (Peppol) mandate, sooner or later you need the list of accredited providers — who is approved to send compliant e-invoices, and who runs the network plumbing.

IMDA publishes it. As a PDF.

Three of them, actually, and they change without notice. Here is how I ended up handling that.

The sources

IRSP list  https://file.go.gov.sg/invoicenowirsp.pdf
Access Pts https://file.go.gov.sg/invoicenowap.pdf
FOC list   https://file.go.gov.sg/invoicenow-focpackage-gstbiz.pdf
Enter fullscreen mode Exit fullscreen mode

The landing page that links them is JavaScript-rendered, so curl on the HTML gets you nothing — the provider names simply are not in the markup. The PDFs themselves are static and fetch fine, which makes them the better integration point anyway.

Parsing

pdftotext was not on the box, so pypdf it is:

import re
from pypdf import PdfReader

def rows(path):
    text = "\n".join((p.extract_text() or "") for p in PdfReader(path).pages)
    out = {}
    for line in (l.strip() for l in text.splitlines() if l.strip()):
        m = re.match(r"^(\d{1,3})\.\s+(.*)", line)
        if m:
            n = int(m.group(1))
            out.setdefault(n, m.group(2))   # first line of a wrapped row wins
    return out
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

Rows wrap across lines. A single provider spans four or five text lines — name, website, support email, phone. Only the first carries the index. setdefault keeps that one and ignores the continuation lines.

Do not trust max() for the count. My first pass reported 365 entries. The real number is 209 — phone numbers and other stray digits matched the row pattern. Walk the contiguous run instead:

def count(rows):
    n = 1
    while n in rows:
        n += 1
    return n - 1
Enter fullscreen mode Exit fullscreen mode

That is the difference between a plausible wrong answer and a right one, and it is the kind of thing that silently poisons a dataset.

What the numbers actually are

As of the 17 September 2026 revision:

List Entries Dated
InvoiceNow-Ready Solution Providers (IRSP) 209 17 Sep 2026
Access Point Providers (AP) 47 9 Sep 2026
Free-of-charge packages 13 17 Sep 2026

The IRSP and AP lists carry different revision dates, so treat them as independent feeds rather than one dataset.

The finding that surprised me

Of the 209 IRSP entries, rows 140 to 209 are all Xero — 70 accounting firms reselling the same product, each listed separately.

So "209 accredited providers" is a misleading headline number. In terms of distinct software you might actually run, the list is roughly a third of its apparent size. If you are building a picker UI, collapse by solution name or you will show a user seventy identical options.

xero = {n for n, v in rows.items() if v.startswith("Xero")}
# 71 rows, of which 140..209 are a contiguous reseller block
Enter fullscreen mode Exit fullscreen mode

Diffing, because it moves

That 209 was 203 about a week earlier. There is no changelog and no version endpoint — the PDF is replaced in place. If anything you ship depends on the list, fetch it on a schedule, parse to a normalised structure, and diff against what you had:

added   = set(new) - set(old)
removed = set(old) - set(new)
Enter fullscreen mode Exit fullscreen mode

The header line carries Updated as of <date>, which is the cheapest change signal available — grab it before parsing anything else.

Why bother

Under the IRAS GST InvoiceNow Requirement, newly GST-registered businesses come onboard from 2026, with existing registrants phased in by turnover through 2031. Around 90,000 businesses land in scope. "Is my software on the list?" turns out to be the first question almost everyone asks, and it is a question a PDF answers badly.

I turned the parsed output into a couple of free checkers for non-technical owners — search your accounting software against the accredited list, and compare the free-of-charge packages including the three that expire on 31 March 2027 rather than 2031. Static HTML, no signup, no analytics.

If you are parsing these lists too and have found a cleaner approach than regex over extract_text(), I would like to hear it — the wrapped-row handling still feels fragile to me.

Top comments (0)