Validate a GSTIN in Node.js and TypeScript
Offline GST number validation in JavaScript and TypeScript — checksum, types, a Zod schema, and a React input that explains itself.
Validating a GSTIN in JavaScript is arithmetic, not a network call. Fifteen characters, a format pattern, and a check digit recomputed from the first fourteen — no API key, no latency, no cost. It runs in a browser, in an Express handler, in a Lambda, in a loop over a million rows.
This article gives you the whole thing twice: written by hand so you own it, and then as a maintained package so you do not have to. After that, the parts people actually get wrong — the type of the result, the Zod schema, the React input, and the line between what belongs in the browser and what never does.
Write it yourself in twenty lines
Start here even if you plan to install something — knowing what the check is makes every later decision
obvious. A GSTIN is 15 characters from 0-9 and A-Z: two state-code digits, a
ten-character PAN, an entity code, a literal Z, and a check digit. Validation is three steps —
normalise, match the pattern, recompute the last character:
// gstin.js — no dependencies, runs anywhere
const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// Note [1-9A-Z] for the entity code: position 13 is never a zero.
const PATTERN = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
export function normalise(value) {
return String(value ?? '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}
export function checkDigitFor(first14) {
let sum = 0;
for (let i = 0; i < 14; i++) {
const value = CHARSET.indexOf(first14[i]);
if (value === -1) return null;
// Weights alternate 1, 2 from index 0; the fold is the base-36
// equivalent of Luhn's "add the digits" when a double exceeds the base.
const product = value * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
// The outer % 36 matters: when sum is a multiple of 36, 36 - 0 is 36,
// which is one past the end of the alphabet.
return CHARSET[(36 - (sum % 36)) % 36];
}
export function isValidGstin(value) {
const gstin = normalise(value);
if (!PATTERN.test(gstin)) return false;
return checkDigitFor(gstin.slice(0, 14)) === gstin[14];
}
That is the entire algorithm. Test it against numbers chosen to break careless implementations:
isValidGstin('27AAACR5055K1Z7'); // true — Reliance, Maharashtra
isValidGstin('29AAACI1195H1ZI'); // true — check digit is a LETTER
isValidGstin('27-aaacr 5055k1z7'); // true — case and punctuation must normalise
isValidGstin('27AAACR5055K1Z0'); // false — check digit off by one
isValidGstin('27AAACR5055K1Y7'); // false — position 14 is not Z
isValidGstin('27AAACR5055K0Z7'); // false — entity code 0 is never issued
isValidGstin('00AAACR5055K1ZN'); // true — and yet 00 is not a real state code
Or install it
The twenty lines above are correct and will stay correct. What they do not carry is the state-code table, the PAN holder-type map, the human-readable rejection messages, or the field-by-field breakdown — all of which you will want the moment a user asks why their number was refused.
npx @dlminds/gstin-api explain 27AAACR5055K1Z7 # try it with nothing installed
npm install @dlminds/gstin-api
The CLI also does validate, parse, states and lookup,
takes --json, and reads stdin, which makes it a reasonable way to check a CSV column without
writing anything. The
package itself has zero
runtime dependencies, targets Node 18+, is written in TypeScript, ships both ESM and CommonJS with type
declarations, and is MIT licensed. Its full export list is on
the npm package reference.
import { isValidGstin, gstinRejectionReason, normalizeGstin, parseGstin } from '@dlminds/gstin-api';
isValidGstin('27AAACR5055K1Z7'); // true
normalizeGstin(' 27-aaacr 5055k1z7 '); // '27AAACR5055K1Z7'
// null when valid; a sentence you can show the user when it is not.
gstinRejectionReason('27AAACR5055K1Z7'); // null
gstinRejectionReason('27AAACR5055K1Z0'); // 'The check digit does not match...'
const parsed = parseGstin('27AAACR5055K1Z7');
parsed.state; // 'Maharashtra'
parsed.pan; // 'AAACR5055K'
parsed.panHolderType; // 'Company'
parsed.expectedCheckDigit; // '7'
gstinRejectionReason is the one worth noticing. It is user-facing copy you did not have to
write, and it says which rule failed rather than "invalid GSTIN" — which is the difference between
a user fixing their typo and a user emailing support.
| Function | Returns | Use it for |
|---|---|---|
isValidGstin | boolean | The guard clause. Format and checksum together |
gstinRejectionReason | string | null | The message under the input. null means valid |
matchesGstinFormat | boolean | Pattern only — useful for "keep typing" states |
normalizeGstin | string | What you store. Always |
parseGstin | ParsedGstin | Everything at once, as a plain object |
explainGstin | GstinPart[] | A labelled breakdown for a UI. [] unless 15 chars |
stateForCode, allStates, searchStates | GstState data | State dropdowns, UTGST checks, code lookups |
new GstinApiClient() | Promises | The register lookup — server only |
Typing the result properly
parseGstin returns a plain object with everything on it, valid or not. That is deliberate — a
failed parse still tells you the state code it saw and the check digit it expected. But at your application
boundary you usually want the stronger shape: a discriminated union that makes the compiler stop you from
reading a state name off a number that was never valid.
// gstin.ts
import { parseGstin, type ParsedGstin } from '@dlminds/gstin-api';
export type GstinCheck =
| { ok: true; gstin: string; state: string; holderType: string }
| { ok: false; input: string; reason: string };
export function checkGstin(input: string): GstinCheck {
const parsed: ParsedGstin = parseGstin(input);
if (!parsed.valid) {
// reason is null only when valid, so this branch always has one.
return { ok: false, input: parsed.input, reason: parsed.reason ?? 'Not a valid GSTIN.' };
}
return {
ok: true,
// The normalised form — this is the value that goes in the database.
gstin: parsed.gstin,
// stateKnown is false for codes like 00 that pass the checksum but
// were never issued, so do not assume state is populated.
state: parsed.stateKnown ? parsed.state : 'Unrecognised state code',
holderType: parsed.panHolderType,
};
}
The union is doing real work. result.state does not typecheck until you have narrowed on
result.ok, so the "we forgot to handle the invalid case" bug becomes a compile error rather
than undefined in a template. The library also exports ParsedGstin,
GstinPart, GstState, Taxpayer, Address,
Filing, VerificationResult and BulkResult if you would rather build
on its own types.
A Zod schema
If your forms or your API bodies already go through Zod, this is where GSTIN validation belongs — one schema, reused by the client form and the server handler, normalising as it validates.
// schema.ts
import { z } from 'zod';
import { normalizeGstin, gstinRejectionReason } from '@dlminds/gstin-api';
export const gstinSchema = z
.string()
.trim()
// Transform BEFORE refining, so the parsed output is the value you store
// and the checks run against the cleaned string, not the pasted one.
.transform(normalizeGstin)
.superRefine((value, ctx) => {
const reason = gstinRejectionReason(value);
if (reason) {
// The library's message is specific enough to show verbatim.
ctx.addIssue({ code: z.ZodIssueCode.custom, message: reason });
}
});
export const vendorSchema = z.object({
name: z.string().min(1),
gstin: gstinSchema,
});
vendorSchema.parse({ name: 'Acme', gstin: ' 27-aaacr 5055k1z7 ' });
// { name: 'Acme', gstin: '27AAACR5055K1Z7' } <- normalised on the way out
An input that explains itself
The point of doing this offline is that feedback can be instant. A GSTIN field should tell the user what is wrong while they type, and — once the number is valid — confirm what it decoded to, so a wrong-but-valid number stands out too.
// GstinField.tsx
import { useState } from 'react';
import { parseGstin } from '@dlminds/gstin-api';
export function GstinField({ onValid }: { onValid: (gstin: string) => void }) {
const [raw, setRaw] = useState('');
// Cheap enough to run on every keystroke — it is pure arithmetic.
const parsed = parseGstin(raw);
// Do not shout at someone who has typed four characters so far.
const showError = raw.trim().length >= 15 && !parsed.valid;
return (
<div>
<input
value={raw}
autoCapitalize="characters"
spellCheck={false}
aria-invalid={showError}
aria-describedby="gstin-hint"
onChange={(event) => {
setRaw(event.target.value);
const result = parseGstin(event.target.value);
// Hand the parent the NORMALISED value, never the raw input.
if (result.valid) onValid(result.gstin);
}}
/>
<p id="gstin-hint">
{parsed.valid
? `${parsed.state} · ${parsed.panHolderType} · PAN ${parsed.pan}`
: showError
? parsed.reason
: 'A GSTIN is 15 characters, for example 27AAACR5055K1Z7.'}
</p>
</div>
);
}
Two details carry most of the value. The parent receives parsed.gstin — the normalised string —
so nothing downstream ever sees the user's spacing. And the valid state shows the decoded state name and
entity type rather than a green tick, because "Maharashtra · Company" is checkable by a human in a way that
a tick is not. More form patterns, including the paste-from-PDF cases, are in
GST number validation in forms.
Server-side is the one that counts
Everything above is user experience. A request can arrive from curl, from a stale bundle, or from someone who opened devtools — so the same schema has to run again on the server, and for anything that matters the server also asks the register whether the registration exists.
// server.ts
import express from 'express';
import { GstinApiClient, InvalidGstinError, GstinApiError } from '@dlminds/gstin-api';
import { gstinSchema } from './schema';
const app = express();
app.use(express.json());
// Reads GSTINAPI_API_KEY from process.env when apiKey is omitted.
const client = new GstinApiClient();
app.post('/api/vendors', async (req, res) => {
// Re-validate offline. Free, and it stops a typo costing a credit.
const parsed = gstinSchema.safeParse(req.body.gstin);
if (!parsed.success) {
return res.status(422).json({ error: parsed.error.issues[0].message });
}
try {
const taxpayer = await client.lookup(parsed.data);
// null means well-formed but not on the register — a fraud signal,
// not a typo. Treat the two differently.
if (!taxpayer) {
return res.status(422).json({ error: 'No GST registration exists for that number.' });
}
return res.status(201).json({
gstin: parsed.data,
legalName: taxpayer.name,
// ISO 'YYYY-MM-DD' strings, not Date objects — these are calendar
// dates and have no timezone to lose.
registeredOn: taxpayer.registrationDate,
});
} catch (error) {
// Thrown before any request goes out, so it never costs a credit.
if (error instanceof InvalidGstinError) {
return res.status(422).json({ error: error.message });
}
// isFatal separates "stop, this will not get better" (bad key, no
// credits) from "retry later" (rate limit, transport blip).
if (error instanceof GstinApiError && !error.isFatal) {
return res.status(503).json({ error: 'Verification is temporarily unavailable.' });
}
throw error;
}
});
The error hierarchy is worth learning once: GstinApiError is the base, with
InvalidGstinError, AuthenticationError, InsufficientCreditsError,
RateLimitError, ServiceError and TransportError underneath, each
carrying message, status and isFatal. Rejecting a legitimate vendor
because your own provider had a bad minute is the most common bug in these integrations — retries and
backoff are covered in integrating the
verification API in Node.js.
What is safe to run in the browser
The offline half of the library imports nothing from node:. It is isomorphic, it tree-shakes,
and bundling isValidGstin alone does not drag the HTTP client in with it. So the split is
clean, and it is not a matter of taste:
| Runs in the browser | Server only |
|---|---|
| Checksum and format checks | Register lookups |
parseGstin, explainGstin | Anything holding GSTINAPI_API_KEY |
| State tables and PAN holder types | Filing history and status |
| Everything that is pure arithmetic | Everything that costs a credit |
Validating a whole list
Vendor masters arrive as spreadsheets, and the cheapest request is the one you never send. Dedupe, filter offline, and only then go to the network — on a typical import that removes most of the rows before anything leaves the process.
import { normalizeGstin, isValidGstin, GstinApiClient } from '@dlminds/gstin-api';
const client = new GstinApiClient();
export async function screen(rows: string[]) {
// Normalise first, so '27 AAACR5055K1Z7' and '27aaacr5055k1z7' collapse
// into one entry instead of two lookups for the same registration.
const unique = [...new Set(rows.map(normalizeGstin))];
const malformed = unique.filter((gstin) => !isValidGstin(gstin));
const candidates = unique.filter(isValidGstin);
// verifyMany dedupes again internally and bounds concurrency for you —
// never Promise.all over the whole array.
const bulk = await client.verifyMany(candidates, { concurrency: 5 });
return {
malformed, // rejected for free, before any request
verified: bulk.results,
lookedUp: bulk.lookedUp, // what you actually paid for
skipped: bulk.skipped, // duplicates the client caught
stoppedEarly: bulk.stoppedEarly, // true if a fatal error cut it short
};
}
stoppedEarly is the field people forget to read. If your key runs out of credits halfway
through a ten-thousand-row import, you want to know the tail of the list is unprocessed rather than clean.
That is the whole picture: a checksum you can own, a package when you would rather not, a typed result, one schema shared by form and server, live feedback in the input, and a bulk path that spends nothing on rows it can reject for free. Where offline validation stops and verification begins is validation vs verification; the pattern is picked apart in the GSTIN regex explained and GSTIN format explained; the fraud angle is how to check a fake GST number. For one-off answers, the browser-side GSTIN validator and GST number search. Live status checks use the GST verification API, whose endpoints are in the API documentation; a key takes a minute at registration.
Frequently asked questions
A note on accuracy. GST rules change often. This article reflects our understanding as of 8 September 2026 and is general information, not tax or legal advice. For the authoritative position, check gst.gov.in and cbic-gst.gov.in, or speak to a qualified tax professional about your specific situation.
Check a GSTIN right now, free
Five lookups a day, no account required. Status, filing history, e-invoicing and jurisdiction.
Free GST search toolBuild it into your product
One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.
Start freeMore on developer integration guides
Browse every guide in the GST API blog.