npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@dlminds/gstin-api — GSTIN validation and GST verification for Node.js

npm Types License: MIT Zero dependencies

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-api
import { 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 27AAACR5055K1Z7

Contents


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-api

Both module systems work, with no esModuleInterop gymnastics:

import { isValidGstin } from '@dlminds/gstin-api';        // ESM / TypeScript
const { isValidGstin } = require('@dlminds/gstin-api');   // CommonJS

There 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');       // false

When 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 valid

parseGstin 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 Karnataka

Verifying 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:

  1. Duplicates are looked up once. The same GSTIN five times in an export costs one credit, not five.
  2. Malformed rows never leave the process. A column full of typos is free.
  3. 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, stoppedEarly is set, and the rows already fetched still come back on bulk.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 issued

Validity 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_KEY

Exit 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

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 build

License

MIT © GSTIN API