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