Skip to main content
gst-validator

gst-validator

Validate Indian GSTINs offline and pull taxpayer details from the public GST portal.

PyPI Python CI License Typed Downloads

Install · CLI · Library · Data · Contributing


A typed Python library and CLI for the Indian GST taxpayer search. It checks a GSTIN's structure and checksum without touching the network, and wraps the portal's undocumented endpoints in objects you can actually hold.

from gst_validator import GSTIN, GSTClient

GSTIN.is_valid("27AAACR5055K1Z7")  # True, offline, no network

with GSTClient() as client:
    client.fetch_goods_and_services("27AAACR5055K1Z7")  # no captcha needed

Why this exists

The GST portal has no public API for taxpayer search. What it has is a captcha-gated web form and a handful of undocumented JSON endpoints that return "NA" for null, dd/mm/yyyy for dates, two different shapes for the same field, and HTTP 200 for errors. This package absorbs that so your code sees datetime.date, None and exceptions.

Offline validation Format and mod-36 checksum, plus state, PAN and entity type decoded from the number
Typed objects Dates parsed, "NA" / "" / null normalised, nothing silently dropped
Captcha, your way Raw bytes, base64 or a data: URI, so a browser, a human or a service can solve it
Three free endpoints HSN/SAC codes, financial years and filing preferences need no captcha at all
Sync and async The same API with await, both strict-typed and py.typed
Caching built in A lookup costs a human-solved captcha, so results are cached by default

Install

uv add gst-validator          # into a uv project
pip install gst-validator     # or plain pip
uvx gst-validator --help      # or run it without installing

Python 3.13 or newer. The only runtime dependency is httpx.

Quick start

# Is this GSTIN well-formed? No network, no captcha.
gst-validator 27AAACR5055K1Z7 --offline

# Full lookup: writes the captcha image, waits for you to type it.
gst-validator 27AAACR5055K1Z7 --json

CLI

gst-validator [-h] [--offline] [--json] [--details-only] [--raw]
              [--captcha-path PATH] [--captcha-base64] [--refresh]
              [--keep-captcha] GSTIN

Validate without touching the network

$ gst-validator 27AAACR5055K1Z7 --offline
27AAACR5055K1Z7 is valid (state 27, PAN AAACR5055K)

$ gst-validator 27AAACR5055K1Z7 --offline --json
{"gstin": "27AAACR5055K1Z7", "state_code": "27", "pan": "AAACR5055K"}

Use this in CI, in a pre-commit check, or to screen input before spending a captcha. Exit code 2 means the GSTIN is malformed.

Full lookup (interactive)

$ gst-validator 27AAACR5055K1Z7
captcha image written to /tmp/27AAACR5055K1Z7-captcha.png   # stderr
captcha text: 784077
gstin                  27AAACR5055K1Z7
legal_name             <registered name as the portal returns it>
status                 Active
principal_address      <registered place of business, one line>
goods_and_services     39269080 - POLYPROPYLENE ARTICLES, NOT ELSEWHERE SPECIFIED...
financial_years        2017-2018, 2018-2019, ...
...

Open the image, type the text. The file is deleted once you have entered it.

Machine-readable output

gst-validator 27AAACR5055K1Z7 --json     # modelled fields, dates as ISO strings
gst-validator 27AAACR5055K1Z7 --raw      # the portal's body verbatim, nothing dropped

--json is the one to parse: stable key names, null instead of "NA", dates as 2025-09-15. --raw is for debugging what the portal actually sent. Both go to stdout; progress messages go to stderr, so piping is safe:

gst-validator 27AAACR5055K1Z7 --json | jq -r '.legal_name, .principal_address'

Solving the captcha somewhere else

$ gst-validator 27AAACR5055K1Z7 --captcha-base64
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALY...
captcha text: 784077

The data URI goes to stdout. Paste it into a browser address bar, drop it in an <img src=...>, or hand it to a solving service. The process keeps the portal session open while it waits on stdin, which is what makes this work.

Other flags

gst-validator 27AAACR5055K1Z7 --details-only   # skip the captcha-free extras
gst-validator 27AAACR5055K1Z7 --refresh        # ignore the cache, force a fresh lookup
gst-validator 27AAACR5055K1Z7 --keep-captcha   # keep the image file for inspection
gst-validator 27AAACR5055K1Z7 --captcha-path ./c.png   # write it where you want

Also runnable as a module: python -m gst_validator 27AAACR5055K1Z7.

Exit codes

Code Meaning
0 success
1 lookup failed (wrong captcha, portal error, network)
2 GSTIN failed format or checksum validation
130 aborted (Ctrl-C / EOF)
if gst-validator "$GSTIN" --offline >/dev/null 2>&1; then
  echo "well-formed"
fi

Using it in your app

Everything is importable from the package root:

from gst_validator import (
    GSTIN,
    Captcha,
    GSTClient,
    AsyncGSTClient,
    TaxpayerDetails,
    TaxpayerProfile,
    Address,
    Jurisdiction,
    GoodsOrService,
    FinancialYear,
    FilingPreference,
    TTLCache,
    NullCache,
    DEFAULT_CACHE,
    TaxpayerCache,
    GSTValidatorError,
    InvalidGSTINError,
    CaptchaError,
    TaxpayerLookupError,
)

1. Validate a GSTIN (no network, no captcha)

from gst_validator import GSTIN, InvalidGSTINError

GSTIN.is_valid("27AAACR5055K1Z7")  # True  - never raises
GSTIN.is_valid("27AAACR5055K1ZA")  # False - checksum digit is wrong

gstin = GSTIN.parse(" 27aatcm7522p1zj ")  # strips, upper-cases, validates
gstin.value  # '27AAACR5055K1Z7'
gstin.state_code  # '27'
gstin.state_name  # 'Maharashtra'
gstin.pan  # 'AAACR5055K'
gstin.entity_type  # 'Company'   (4th PAN character)
gstin.registration_sequence  # '1'         (Nth registration of this PAN in this state)

try:
    GSTIN.parse(user_input)
except InvalidGSTINError as error:
    print(error.value, error.reason)  # the input, and why it was rejected

GSTIN is a frozen dataclass: hashable, comparable, usable as a dict key. Take one as a function parameter and malformed input cannot reach your code.

2. The data you get without a captcha

Three portal endpoints return data with no captcha at all, verified against the live portal with no cookies and no prior captcha solve. The client still opens a session first, since the portal could tighten this at any time:

from gst_validator import GSTClient

with GSTClient() as client:
    client.fetch_goods_and_services("27AAACR5055K1Z7")
    # (GoodsOrService(code='55151190', description='OTHER', is_service=False),
    #  GoodsOrService(code='39269080', description='POLYPROPYLENE ARTICLES, NOT
    #                 ELSEWHERE SPECIFIED OR INCLUDED', is_service=False), ...)
    # Service providers come back as SAC codes instead, with is_service=True:
    #   GoodsOrService(code='998314', description='Information technology
    #                  design and development services', is_service=True)

    client.fetch_financial_years("27AAACR5055K1Z7")
    # (FinancialYear(label='2025-2026', value='2025'), FinancialYear('2026-2027', '2026'))

    client.fetch_filing_preferences("27AAACR5055K1Z7")
    # (FilingPreference(quarter='Q1', preference='Q'), ...)   -> .is_quarterly / .is_monthly

3. The full lookup (one captcha)

The captcha is bound to the client's cookies, so fetch and submit must happen on the same instance:

with GSTClient() as client:
    captcha = client.fetch_captcha()
    solved = input(f"solve this: {captcha.data_uri}\n> ")
    profile = client.fetch_profile("27AAACR5055K1Z7", solved)

profile.name  # registered trade name, else the legal name
profile.is_active  # True
profile.details  # TaxpayerDetails
profile.as_dict()  # everything, JSON-ready

fetch_details() instead of fetch_profile() if you only want the captcha-gated part.

4. Web app: captcha to the browser, text back

The pattern the original Flask app was reaching for: keep one client per pending lookup, keyed by a session id:

import uuid
from fastapi import FastAPI, HTTPException
from gst_validator import GSTClient, GSTValidatorError, InvalidGSTINError

app = FastAPI()
pending: dict[str, GSTClient] = {}  # swap for Redis + a TTL in production


@app.post("/captcha")
def start() -> dict[str, str]:
    client = GSTClient()
    captcha = client.fetch_captcha()
    session_id = str(uuid.uuid4())
    pending[session_id] = client
    return {"session_id": session_id, "image": captcha.data_uri}


@app.post("/lookup")
def lookup(session_id: str, gstin: str, captcha: str) -> dict[str, object]:
    client = pending.pop(session_id, None)
    if client is None:
        raise HTTPException(400, "unknown or expired session")
    try:
        return client.fetch_profile(gstin, captcha).as_dict()
    except InvalidGSTINError as error:
        raise HTTPException(422, str(error)) from error
    except GSTValidatorError as error:
        raise HTTPException(502, str(error)) from error
    finally:
        client.close()

The front end renders image straight into <img src="{{ image }}">, since it is already a data: URI. Give pending an expiry; portal sessions do not live forever, and an abandoned entry leaks a connection pool.

5. Async

Same API, await and async with:

import asyncio
from gst_validator import AsyncGSTClient


async def codes(gstin: str) -> tuple[str, ...]:
    async with AsyncGSTClient() as client:
        items = await client.fetch_goods_and_services(gstin)
        return tuple(item.code or "" for item in items)


asyncio.run(codes("27AAACR5055K1Z7"))

6. Caching

Each live lookup costs a human-solved captcha, so successful results are cached in a process-wide TTLCache (24 h, 512 entries, LRU, thread-safe).

from gst_validator import DEFAULT_CACHE, GSTClient, NullCache, TTLCache

GSTClient()  # shares DEFAULT_CACHE
GSTClient(cache=TTLCache(ttl=300))  # private, 5-minute cache
GSTClient(cache=NullCache())  # caching off

with GSTClient() as client:
    if (hit := client.cached(gstin)) is not None:
        details = hit  # no captcha spent
    else:
        details = client.fetch_details(gstin, solved)

    client.fetch_details(gstin, solved, refresh=True)  # bypass and overwrite

Back it with anything that satisfies the TaxpayerCache protocol:

import json
from gst_validator import TaxpayerDetails


class RedisCache:
    def __init__(self, redis, ttl: int = 86_400) -> None:
        self._redis, self._ttl = redis, ttl

    def get(self, gstin: str) -> TaxpayerDetails | None:
        blob = self._redis.get(f"gst:{gstin}")
        return TaxpayerDetails.from_payload(json.loads(blob)) if blob else None

    def set(self, gstin: str, details: TaxpayerDetails) -> None:
        self._redis.setex(f"gst:{gstin}", self._ttl, json.dumps(details.raw))


client = GSTClient(cache=RedisCache(redis_connection))

The client is deliberately not a singleton. It owns the cookies a captcha is bound to, so one shared instance would cross captcha sessions between concurrent lookups. The cache is the shared piece; clients stay cheap and short-lived. The cache stores .raw, so a cached entry survives a model upgrade.

7. Error handling

GSTValidatorError
├── InvalidGSTINError   (also a ValueError)  .value, .reason
├── CaptchaError                             captcha could not be fetched
└── TaxpayerLookupError                      .code = the portal's errorCode
from gst_validator import CaptchaError, GSTValidatorError, InvalidGSTINError, TaxpayerLookupError

try:
    profile = client.fetch_profile(gstin, solved)
except InvalidGSTINError:
    ...  # bad input, never hit the network
except CaptchaError:
    ...  # portal did not hand out an image
except TaxpayerLookupError as error:
    if error.code == "SWEB_9000":
        ...  # wrong or expired captcha - fetch a new one
except GSTValidatorError:
    ...  # catch-all for this package

The portal answers rejections with HTTP 200 and a body carrying an errorCode, so the absence of gstin in the body, not the status code, is what marks a failed lookup. One except GSTValidatorError catches everything this package raises; httpx errors are wrapped, never leaked.


What you get back

TaxpayerProfile

Attribute Type Source
details TaxpayerDetails taxpayerDetails (captcha)
goods_and_services tuple[GoodsOrService, ...] goodservice
financial_years tuple[FinancialYear, ...] dropdownfinyear
filing_preferences tuple[FilingPreference, ...] taxpayerProfileDetails

Shortcuts: gstin, name, is_active, as_dict().

TaxpayerDetails

Attribute Portal key Type
gstin / number gstin str / GSTIN | None
legal_name lgnm str | None
trade_name tradeNam str | None
name (derived) trade name, else legal name
status sts str | None
constitution ctb str | None
taxpayer_type dty str | None
registration_date rgdt datetime.date | None
cancellation_date cxdt datetime.date | None
last_updated lstupdt datetime.date | None
nature_of_business nba tuple[str, ...]
principal_address pradr Address | None
additional_addresses adadr tuple[Address, ...]
central_jurisdiction ctj, ctjCd Jurisdiction
state_jurisdiction stj, stjCd Jurisdiction
einvoice_enabled einvoiceStatus bool | None
is_field_visit_conducted isFieldVisitConducted bool | None
core_business_activity ntcrbs (code expanded) str | None
aadhaar_verified adhrVFlag bool | None
aadhaar_verified_on adhrVdt datetime.date | None
ekyc_status ekycVFlag str | None
composition_rate cmpRt str | None
raw everything dict[str, Any]

Helpers: is_active, is_cancelled, addresses (principal first), as_dict(), and unmapped, which lists portal keys this class does not model, so a new portal field is never silently dropped.

Address carries split fields (building_name, street, pincode, …) and full: the portal usually sends the principal address as one adr string, so as_line() returns whichever form arrived.


Endpoints and what each costs

Method Endpoint Captcha?
fetch_captcha() /services/captcha opens the session
fetch_details() /api/search/taxpayerDetails yes, one per lookup
fetch_goods_and_services() /api/search/goodservice no
fetch_financial_years() /api/dropdownfinyear no
fetch_filing_preferences() /api/search/taxpayerProfileDetails no
fetch_profile() all of the above one

goodservice returns SAC codes for service providers (bzsdtls) and HSN codes for goods (bzgddtls); both are parsed into GoodsOrService, with is_service telling them apart.

The portal fingerprints clients, so the package sends a browser User-Agent and the Referer/Origin headers the site expects; without them the captcha request is reset. For unattended or high-volume use, the official GST API through a licensed GSP is the supported route; this package drives the public, captcha-gated search.


Development

uv sync              # install, including dev dependencies
uv run pytest        # 46 tests, fully offline via httpx.MockTransport
uv run mypy          # strict
uv run pyright       # strict
uv run ruff check .

Tests parse payloads with the exact shape the live portal returns (tests/fixtures/, one service taxpayer and one goods taxpayer, with the identifying values replaced by fictional ones) and assert unmapped == {}, so a portal schema change fails the suite instead of quietly losing data.

All GSTINs in this README and in the tests are fictional placeholders built on the dummy PAN AAACR5055K; they are checksum-valid but belong to nobody.

Releasing

Releases are automated with release-please. Commits on main follow Conventional Commits (feat:, fix:, portal:, ...); a bot keeps a release pull request up to date with the next version number and the changelog. Merging it bumps the version, tags, publishes to PyPI and creates the GitHub release. Nothing is tagged or edited by hand. Details in CONTRIBUTING.md.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the setup and the house rules; the short version:

uv sync
uv run pytest -q && uv run ruff check . && uv run mypy && uv run pyright

Two rules matter more than the rest: tests never touch the network (everything goes through httpx.MockTransport), and no real taxpayer's data in the repo - fixtures use a public company's registration or a fictional, checksum-valid GSTIN.

If the portal changes shape, that is a portal change issue; include the output of --raw with the identifying values replaced.

Security reports go through private advisories, not public issues. See SECURITY.md.

License

MIT. See LICENSE.

Metadata

Release files for gst-validator 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gst-validator 0.1.1
File Size Uploaded
gst_validator-0.1.1.tar.gz 22.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for gst-validator 0.1.1
File Interpreter ABI Platform
gst_validator-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 47.2 kB

Release files / gst_validator-0.1.1.tar.gz

Download URL gst_validator-0.1.1.tar.gz
Size 22.1 kB
Tags Source
SHA-256 checksum
How to use checksums
5e7b8a9f6f3bbb6f191bb79489d669ba0381bc8222a5c4b3d169d3e5169882f4
BLAKE2b-256 checksum
How to use checksums
b1f0ea4db8f72f3cd4ec403672cbde721c6c9e9cad282f7743098bfeddffa93c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / gst_validator-0.1.1-py3-none-any.whl

Download URL gst_validator-0.1.1-py3-none-any.whl
Size 25.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7a59753191097352cbe0bd71e93bd8b7920d2a58cc5698f81dfea5e65457f376
BLAKE2b-256 checksum
How to use checksums
738af7ee61111f3a448978a8d54140b5a96a5f1b692b5f92d2742fa3e6f95abf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.1

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page