gst-validator
Validate Indian GSTINs offline and pull taxpayer details from the public GST portal.
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.
Links
- PyPI
- Changelog
- Official GST developer portal - the licensed GSP route for unattended, high-volume use
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)
| File | Size | Uploaded | |
|---|---|---|---|
| gst_validator-0.1.1.tar.gz | 22.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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