Every Indian GST invoice carries a 15-character GSTIN. If you've ever had to validate one in code, you've probably written a regex like this and called it done:
/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/
That regex checks the shape of a GSTIN. It says nothing about whether the number is real. A typo that happens to land on a syntactically valid string will sail straight through it — and if that typo ends up on an invoice, it's the kind of thing that gets flagged months later during a GST audit.
There's a cheap next step before you go anywhere near a network call: the GSTIN has a built-in checksum character, and you can verify it in a few lines with no API and no dependency.
How the checksum works
A GSTIN is 15 characters: 33AAACC1206D1ZN. The first 14 encode the state code, the business's PAN, and an entity number. The 15th is a checksum digit computed from the other 14, using a 36-character alphabet (0-9 then A-Z) and alternating weights of 1 and 2 — the same family of algorithm as ISO 7064 MOD 37-36.
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function gstinChecksumIsValid(gstin) {
if (!/^[0-9A-Z]{15}$/.test(gstin)) return false;
let sum = 0;
let factor = 1;
for (let i = 0; i < 14; i++) {
const codePoint = ALPHABET.indexOf(gstin[i]);
const digit = factor * codePoint;
sum += Math.floor(digit / 36) + (digit % 36);
factor = factor === 1 ? 2 : 1;
}
const checksumIndex = (36 - (sum % 36)) % 36;
return ALPHABET[checksumIndex] === gstin[14];
}
gstinChecksumIsValid('33AAACC1206D1ZN'); // true
gstinChecksumIsValid('33AAACC1206D1ZM'); // false — last char changed
I hand-verified this against a real, currently-registered GSTIN (Central Warehousing Corporation, 33AAACC1206D1ZN) before trusting it enough to write this post — the algorithm above reproduces the N checksum character exactly.
Python, same logic:
ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
def gstin_checksum_is_valid(gstin: str) -> bool:
import re
if not re.match(r'^[0-9A-Z]{15}$', gstin):
return False
total = 0
factor = 1
for ch in gstin[:14]:
code_point = ALPHABET.index(ch)
digit = factor * code_point
total += digit // 36 + digit % 36
factor = 2 if factor == 1 else 1
checksum_index = (36 - total % 36) % 36
return ALPHABET[checksum_index] == gstin[14]
Drop this in ahead of any network call. It rejects a huge class of typos and malformed input for free, in constant time, offline.
What a checksum can't tell you
A checksum only proves the string is internally consistent — that it could plausibly be a GSTIN. It doesn't tell you:
- whether that GSTIN is actually registered
- whether the registration is still active, or was cancelled
- what legal name it's registered under, so you can catch a mismatch against the invoice
You can construct a string that's checksum-valid and still refers to nobody, or to a business whose registration was cancelled last year. For that you need to ask the actual GST network, which means an API call.
That's the part I build: gstinapi.in is a REST API that takes a GSTIN and returns the legal name, registration status, taxpayer type and registered address, live from India's official GSP network — no scraping, no cached copy.
const { GstinApi } = require('gstinapi'); // npm install gstinapi
const client = new GstinApi({ apiKey: process.env.GSTIN_API_KEY });
const result = await client.lookup('33AAACC1206D1ZN');
console.log(result.data.legal_name); // CENTRAL WAREHOUSING CORPORATION
console.log(result.data.status); // Active
from gstinapi import GstinApi # pip install gstinapi
client = GstinApi(api_key=os.environ["GSTIN_API_KEY"])
result = client.lookup("33AAACC1206D1ZN")
print(result["data"]["legal_name"]) # CENTRAL WAREHOUSING CORPORATION
print(result["data"]["status"]) # Active
Every account gets 100 free lookups a month, no credit card, and it renews every month rather than being a one-time trial credit — plenty to validate the idea before you need volume.
Source for the client libraries: gstinapi-node and gstinapi-python. Full API reference is at gstinapi.in/docs.
If you build GSTIN validation into something, I'd like to hear where the checksum check alone was enough and where you actually needed the live lookup — drop it in the comments.
Top comments (0)