@dlminds/gstin-api
v0.1.0
Published
GSTIN validation and GST number verification for Node.js and TypeScript — offline checksum validation plus a client for the gstinapi.com GST verification API.
Downloads
34
Maintainers
Readme
@dlminds/gstin-api — GSTIN validation and GST verification for Node.js
Validate any Indian GST number offline, then verify it against the government
register. @dlminds/gstin-api is the official Node.js and TypeScript library for
gstinapi.com. It ships two layers, and a lot of projects
never need the second one:
| | What it does | Network | API key | Cost | | --- | --- | --- | --- | --- | | Offline | Format, modulus-36 check digit, state, embedded PAN, entity type, CGST+SGST vs CGST+UTGST | No | No | Free forever | | Online | Legal name, trade name, whether the registration is still active, constitution, addresses, filing history | Yes | Yes | 1 credit per lookup |
Zero runtime dependencies. Written in TypeScript, shipped as ESM and CommonJS with full type declarations. Node 18+.
npm install @dlminds/gstin-apiimport { isValidGstin, parseGstin } from '@dlminds/gstin-api';
isValidGstin('27AAACR5055K1Z7'); // true
isValidGstin('27AAACR5055K1Z8'); // false — check digit does not match
parseGstin('27AAACR5055K1Z7').state; // 'Maharashtra'
parseGstin('27AAACR5055K1Z7').pan; // 'AAACR5055K'Try it without installing anything:
npx @dlminds/gstin-api explain 27AAACR5055K1Z7Contents
- Why checksum validation is not enough
- Install
- Offline GSTIN validation
- Verifying a GSTIN against the government register
- Bulk verification
- Framework recipes — Express, Zod, NestJS, React
- Error handling
- GST state codes, SGST and UTGST
- Command line
- API reference
- How the GSTIN check digit works
- FAQ
Why checksum validation is not enough
A GSTIN carries its own check digit, so a regex plus twenty lines of arithmetic will reject every typo and every number someone made up on the spot. That is genuinely useful and it is why the offline half of this library exists.
It is also where most integrations stop, and that is the bug. Consider
27AAACR5055K1Z7:
- The format is right.
- The check digit is right.
- It stays right after the registration is cancelled. Checksums do not expire.
Nothing in the fifteen characters tells you whether the business is registered today, whether it was ever registered, whether the name on the invoice is the name on the register, or whether the GSTIN belongs to somebody else entirely. A supplier who deregistered last quarter still hands you a number that passes every offline test — and an invoice from a cancelled GSTIN does not support an input tax credit claim.
So: validate offline to reject garbage for free, verify online to decide whether to pay an invoice. This library does both, in that order, and it will not let you spend a credit on a number that failed step one.
Install
npm install @dlminds/gstin-api
# or
pnpm add @dlminds/gstin-api
# or
yarn add @dlminds/gstin-apiBoth module systems work, with no esModuleInterop gymnastics:
import { isValidGstin } from '@dlminds/gstin-api'; // ESM / TypeScript
const { isValidGstin } = require('@dlminds/gstin-api'); // CommonJSThere are no runtime dependencies, so it drops into a Lambda, a Next.js route handler or an Express app without pulling in a transitive tree. Types are generated from the source, not hand-written.
Offline GSTIN validation
import { isValidGstin, gstinRejectionReason, parseGstin, explainGstin } from '@dlminds/gstin-api';
isValidGstin('27AAACR5055K1Z7'); // true
isValidGstin(' 27-aaacr 5055 k1z7 '); // true — input is normalised first
isValidGstin('27AAACR5055K1Z8'); // falseWhen it says no, ask why. The message is written for the person who typed the number, not for a log file:
gstinRejectionReason('27AAACR5055K1Z8');
// 'Check digit mismatch — expected 7, found 8'
gstinRejectionReason('27AAACR5055K1Y7');
// 'Character 14 must be the literal Z'
gstinRejectionReason('27AAACR5055K1Z');
// 'Wrong length — a GSTIN is 15 characters, this is 14'
gstinRejectionReason('27AAACR5055K1Z7');
// null — it is validparseGstin reads every field the number encodes in a single pass:
const parsed = parseGstin('27AAACR5055K1Z7');
parsed.valid; // true
parsed.state; // 'Maharashtra'
parsed.stateCode; // '27'
parsed.pan; // 'AAACR5055K'
parsed.panHolderType; // 'Company'
parsed.entityCode; // '1'
parsed.registrationNumberInState; // 1
parsed.checkDigit; // '7'
parsed.expectedCheckDigit; // '7'explainGstin returns the same thing as a labelled breakdown, which is what you
want behind a "why was this rejected?" tooltip:
explainGstin('27AAACR5055K1Z7');
// [
// { label: 'Characters 1–2 · State code', value: '27', meaning: 'Maharashtra' },
// { label: 'Characters 3–12 · PAN', value: 'AAACR5055K', meaning: 'PAN of the registered entity — Company' },
// { label: 'Character 13 · Entity code', value: '1', meaning: 'Registration number 1 for this PAN within this state' },
// { label: 'Character 14 · Reserved', value: 'Z', meaning: 'Always Z in the current GSTIN scheme' },
// { label: 'Character 15 · Check digit', value: '7', meaning: 'Checksum matches the first 14 characters' },
// ]Need a checksum-correct GSTIN for a test fixture? buildGstin assembles one:
import { buildGstin } from '@dlminds/gstin-api';
buildGstin({ pan: 'AAACR5055K', stateCode: '27' }); // '27AAACR5055K1Z7'
buildGstin({ pan: 'AAACR5055K', stateCode: '29', entityCode: '2' }); // second registration in KarnatakaVerifying a GSTIN against the government register
Get an API key and put it in the environment — a key in source control is a key on GitHub.
export GSTINAPI_API_KEY="your-api-key"import { GstinApiClient, latestFiling } from '@dlminds/gstin-api';
const client = new GstinApiClient(); // reads process.env.GSTINAPI_API_KEY
const taxpayer = await client.lookup('27AAACR5055K1Z7');
taxpayer?.legalName; // 'RELIANCE INDUSTRIES LIMITED'
taxpayer?.status; // 'Active'
taxpayer?.isActive; // true ← the field you actually came for
taxpayer?.constitution; // 'Public Limited Company'
taxpayer?.taxpayerType; // 'Regular'
taxpayer?.registrationDate; // '2017-07-01'
taxpayer?.principalAddress?.formatted; // 'MAKER CHAMBERS IV, NARIMAN POINT, MUMBAI, …'
taxpayer?.state; // 'Maharashtra Mumbai'
taxpayer?.filingCounts; // { GSTR1: 84, GSTR3B: 84 }
taxpayer?.raw; // the untouched upstream payload
latestFiling(taxpayer!, 'GSTR3B')?.period; // '082024'lookup resolves to null when the number is well-formed but no registration
exists behind it — a real answer, and one the API does not bill for. It throws
InvalidGstinError before making any request if the number fails the
offline checks, so a typo in a CSV can never cost you a credit.
If you would rather have a row than an exception, use verify:
const result = await client.verify('27AAACR5055K1Z8');
result.valid; // false
result.found; // false
result.lookedUp; // false — nothing was sent, nothing was charged
result.message; // 'Not looked up — Check digit mismatch — expected 7, found 8. No credit was spent.'Dates come back as ISO YYYY-MM-DD strings, not Date objects. These are
calendar dates with no time and no zone; turning them into Date would shift
them by a day for anyone outside IST.
Bulk verification
verifyMany is built for the 5,000-row vendor master that lands in your inbox:
const gstins = ['27AAACR5055K1Z7', '29AAACI1195H1ZI', 'not a gstin', '27AAACR5055K1Z7'];
const bulk = await client.verifyMany(gstins, { concurrency: 8 });
bulk.results.length; // 4 — one row per input, in the original order
bulk.lookedUp; // 2 — the duplicate and the typo cost nothing
bulk.skipped; // 2
bulk.stoppedEarly; // false
for (const result of bulk.results) {
if (result.found) {
console.log(result.gstin, result.taxpayer!.legalName, result.taxpayer!.status);
} else {
console.log(result.gstin || result.input, result.message);
}
}Three things happen here that a naive for loop would not do:
- Duplicates are looked up once. The same GSTIN five times in an export costs one credit, not five.
- Malformed rows never leave the process. A column full of typos is free.
- A fatal failure aborts the run. A rejected key or an empty credit balance
will not fix itself on row 2, so the run stops,
stoppedEarlyis set, and the rows already fetched still come back onbulk.results— nobody should have to pay twice because a batch died at row 400 of 500.
Framework recipes
Express middleware
import express from 'express';
import { gstinRejectionReason, normalizeGstin } from '@dlminds/gstin-api';
const app = express();
app.post('/invoices', express.json(), (req, res) => {
const gstin = normalizeGstin(req.body.supplierGstin);
const reason = gstinRejectionReason(gstin);
if (reason !== null) {
return res.status(422).json({ error: `Invalid GSTIN — ${reason}` });
}
// …store the normalised value, never the raw one
});Zod schema
import { z } from 'zod';
import { gstinRejectionReason, normalizeGstin } from '@dlminds/gstin-api';
export const gstinSchema = z
.string()
.transform(normalizeGstin)
.superRefine((value, ctx) => {
const reason = gstinRejectionReason(value);
if (reason !== null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: reason });
}
});
const invoice = z.object({ supplierGstin: gstinSchema, buyerGstin: gstinSchema });NestJS validator
import { registerDecorator, type ValidationOptions } from 'class-validator';
import { gstinRejectionReason } from '@dlminds/gstin-api';
export function IsGstin(options?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'isGstin',
target: object.constructor,
propertyName,
options,
validator: {
validate: (value: unknown) => gstinRejectionReason(value) === null,
defaultMessage: ({ value }) => gstinRejectionReason(value) ?? 'Invalid GSTIN',
},
});
};
}React — live feedback as the user types
import { parseGstin } from '@dlminds/gstin-api';
function GstinInput() {
const [value, setValue] = useState('');
const parsed = parseGstin(value);
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} maxLength={20} />
{value !== '' && !parsed.valid && <p role="alert">{parsed.reason}</p>}
{parsed.valid && <p>{parsed.state} · {parsed.panHolderType}</p>}
</>
);
}The offline half runs in the browser too — it is pure computation with no node:
imports, so bundlers will not complain. Keep the API key on your server, though:
GstinApiClient is for Node, not for a client bundle.
Error handling
Every error carries a message written for a human and a status when there was
an HTTP response. isFatal is the one to branch on in a batch: it marks the
failures that will hit every remaining row.
| Class | Thrown when | isFatal | Charged? |
| --- | --- | --- | --- |
| InvalidGstinError | The number failed the offline checks — no request was made | no | no |
| AuthenticationError | HTTP 401: the API key was missing or rejected | yes | no |
| InsufficientCreditsError | HTTP 402: the account is out of credits | yes | no |
| RateLimitError | HTTP 429, after maxRetries — carries retryAfter | no | no |
| ServiceError | HTTP 5xx from gstinapi.com or the government source | no | no |
| TransportError | DNS, TLS, timeout, abort — no response at all | no | no |
All of them extend GstinApiError.
import { GstinApiClient, GstinApiError, InsufficientCreditsError } from '@dlminds/gstin-api';
try {
const taxpayer = await new GstinApiClient().lookup(gstin);
} catch (error) {
if (error instanceof InsufficientCreditsError) {
await alertFinanceTeam();
} else if (error instanceof GstinApiError) {
logger.warn({ status: error.status }, error.message);
} else {
throw error;
}
}429s, 5xx responses and transport failures are retried automatically — twice by
default, with exponential backoff and full jitter, honouring Retry-After when
the server sends one. 401 and 402 are never retried, because they will not
resolve themselves.
const client = new GstinApiClient({
apiKey: process.env.GSTINAPI_API_KEY,
timeout: 30_000, // milliseconds per request
maxRetries: 2, // extra attempts for retryable failures
baseUrl: 'https://gstinapi.com',
fetch: myInstrumentedFetch, // optional: bring your own HTTP layer
});GST state codes, SGST and UTGST
The first two digits of a GSTIN are the state code, and this library carries the whole table — including the fact that decides which tax you charge:
import { stateForCode, searchStates, allStates, isUtgstState } from '@dlminds/gstin-api';
stateForCode('27')?.name; // 'Maharashtra'
stateForCode(7)?.name; // 'Delhi' — accepts 7, '7' or '07'
stateForCode('04')?.intraStateTax; // 'UTGST' — Chandigarh has no legislature
stateForCode('07')?.intraStateTax; // 'SGST' — Delhi has one, despite being a UT
stateForCode('25')?.legacy; // true — merged into 26 in January 2020
isUtgstState('04'); // true
allStates().length; // 41
searchStates('andhra').map((s) => s.code); // ['28', '37']That Delhi line is the one that bites people. A union territory without a legislature levies CGST + UTGST; Delhi, Puducherry and Jammu and Kashmir have legislatures and levy CGST + SGST like a state. A hand-rolled "is it a UT?" check gets this wrong every time.
An unrecognised state code — 00, 40 — is reported separately rather than
folded into validity:
import { isValidGstin, hasKnownStateCode } from '@dlminds/gstin-api';
isValidGstin('00AAACR5055K1ZN'); // true — the checksum genuinely is correct
hasKnownStateCode('00AAACR5055K1ZN'); // false — but 00 is not issuedValidity has to mean the same thing here as it does server-side, so an odd state code is a warning you act on, not a verdict this library changes on its own.
Command line
The package ships a gstin-api binary, so npx works with nothing installed.
Everything except lookup runs offline.
$ npx @dlminds/gstin-api validate 27AAACR5055K1Z7
valid 27AAACR5055K1Z7
$ npx @dlminds/gstin-api explain 27AAACR5055K1Z7
Characters 1–2 · State code 27 Maharashtra
Characters 3–12 · PAN AAACR5055K PAN of the registered entity — Company
Character 13 · Entity code 1 Registration number 1 for this PAN within this state
Character 14 · Reserved Z Always Z in the current GSTIN scheme
Character 15 · Check digit 7 Checksum matches the first 14 characters
$ cut -d, -f3 invoices.csv | npx @dlminds/gstin-api validate --json > report.json
$ npx @dlminds/gstin-api states delhi
07 Delhi union_territory CGST + SGST
$ npx @dlminds/gstin-api lookup 27AAACR5055K1Z7 # needs GSTINAPI_API_KEYExit codes are meant for scripts: 0 when every number checked out, 1 when at
least one did not, 2 for a usage or configuration problem.
API reference
Full signatures in docs/api-reference.md.
Offline
| Function | Returns |
| --- | --- |
| isValidGstin(value) | boolean — format and check digit |
| gstinRejectionReason(value) | string \| null — why it failed, in plain words |
| matchesGstinFormat(value) | boolean — pattern only, checksum ignored |
| gstinCheckDigit(first14) | string \| null — the 15th character |
| normalizeGstin(value) | string — uppercased, punctuation stripped |
| parseGstin(value) | ParsedGstin — every derived field, one pass |
| explainGstin(value) | GstinPart[] — labelled breakdown |
| buildGstin({ pan, stateCode, entityCode? }) | string — checksum-correct GSTIN |
| isValidPan(value) | boolean — PAN format |
| gstinStateCode / gstinStateName | string \| null |
| gstinPan / panHolderType | string \| null |
| gstinEntityCode / registrationNumberInState | string \| null / number \| null |
| hasKnownStateCode(value) | boolean |
State codes
| Function | Returns |
| --- | --- |
| stateForCode(code) | GstState \| null |
| allStates() | GstState[] |
| searchStates(query) | GstState[] |
| isUtgstState(code) | boolean |
| STATE_CODES | Readonly<Record<string, string>> |
| UTGST_CODES, LEGACY_CODES, NON_GEOGRAPHIC_CODES | readonly string[] |
Online
| Member | Returns |
| --- | --- |
| new GstinApiClient(options?) | client (options may also be the key as a string) |
| .lookup(gstin) | Promise<Taxpayer \| null> — throws on failure |
| .verify(gstin) | Promise<VerificationResult> — a row, not an exception |
| .verifyMany(gstins, { concurrency }) | Promise<BulkResult> |
| latestFiling(taxpayer, type) | Filing \| null |
Every shape is exported as a type: ParsedGstin, GstinPart, GstState,
Taxpayer, Address, Filing, VerificationResult, BulkResult.
How the GSTIN check digit works
Fifteen characters: 27 AAACR5055K 1 Z 7 — state code, PAN, entity code,
a reserved Z, and a check digit.
The check digit is a modulus-36 Luhn variant. Each character's value is its index
in 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ. Weights alternate 1, 2, 1, 2 … across
the first fourteen characters. Each product is folded back into a single value by
adding its quotient and remainder over 36, and the check digit is the complement
of the total:
const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function checkDigit(first14: string): string {
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = CHARSET.indexOf(first14[i]!) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
return CHARSET.charAt((36 - (sum % 36)) % 36);
}
checkDigit('27AAACR5055K1Z'); // '7'The long version — why modulus 36 and not 10, what the algorithm does and does
not catch, and the transposition case it misses — is in
docs/gstin-checksum-algorithm.md.
FAQ
How do I validate a GST number in Node.js?
npm install @dlminds/gstin-api, then isValidGstin('27AAACR5055K1Z7'). It checks the
15-character format and the modulus-36 check digit offline, with no API key and
no network call.
Can I check whether a GSTIN is active without an API key?
No, and neither can anything else. Registration status lives in the government
register and changes over time; the number itself carries no expiry. That is what
GstinApiClient.lookup is for.
Does this work in the browser?
The offline half does — no node: imports, no polyfills, and it tree-shakes, so
importing isValidGstin alone pulls in a couple of kilobytes. GstinApiClient
is server-side only: putting an API key in a client bundle publishes it.
Is there a free GST verification API?
gstinapi.com has a free tier and a free GST number search tool for one-off checks. The offline half of this library is free and unlimited forever, because it never calls anything.
Does it work with CommonJS?
Yes. The package ships both ESM and CommonJS builds with matching type
declarations, so import and require both work without configuration.
How do I verify GST numbers in bulk?
client.verifyMany(listOfGstins). It deduplicates, skips malformed rows for
free, runs lookups concurrently, and stops the run if your credits run out —
returning everything it fetched.
What does the 14th character Z mean?
Nothing yet. It is reserved for future use and is a literal Z in every GSTIN
issued under the current scheme, which makes it a cheap way to catch a mistyped
number.
Can two businesses have the same PAN in a GSTIN?
Yes — one PAN, one GSTIN per state, and multiple registrations within one state
are numbered by character 13. registrationNumberInState reads it: 1 is the
first, 2 the second, then 9, A, B and onward.
Is this an official government library?
No. It is the official Node.js client for gstinapi.com, which reads from the GSTN data source. The offline algorithm is the one GSTN publishes.
Related
- Python:
gstin-apion PyPI — the same API surface, same parity corpus. - Google Sheets: the GSTIN Verifier add-on for people who do not write code.
- API docs: https://gstinapi.com/docs
- Free tools: GST number search · GST state code finder
Contributing
Bug reports and pull requests are welcome — see CONTRIBUTING.md.
One thing to know before changing the validation rules: this library is pinned to
a shared corpus (test/fixtures/gstins.json) that the API's own PHP
implementation and the Google Sheets add-on also assert against. If a change
turns test/corpus.test.ts red, the rules moved — check what the API now does
before "fixing" the library to match a new expectation.
npm install
npm test
npm run typecheck
npm run buildLicense
MIT © GSTIN API
