How-to 10 min read

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.

Written and reviewed by the GSTIN API team Last reviewed

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.

FunctionReturnsUse it for
isValidGstinbooleanThe guard clause. Format and checksum together
gstinRejectionReasonstring | nullThe message under the input. null means valid
matchesGstinFormatbooleanPattern only — useful for "keep typing" states
normalizeGstinstringWhat you store. Always
parseGstinParsedGstinEverything at once, as a plain object
explainGstinGstinPart[]A labelled breakdown for a UI. [] unless 15 chars
stateForCode, allStates, searchStatesGstState dataState dropdowns, UTGST checks, code lookups
new GstinApiClient()PromisesThe register lookup — server only
Everything in this table is offline. Only the client at the bottom touches the network.

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 browserServer only
Checksum and format checksRegister lookups
parseGstin, explainGstinAnything holding GSTINAPI_API_KEY
State tables and PAN holder typesFiling history and status
Everything that is pure arithmeticEverything that costs a credit
An API key in a browser bundle is a published API key. There is no configuration that fixes that.

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

Normalise the string to uppercase alphanumerics, test it against the pattern ^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$, then recompute the fifteenth character from the first fourteen using the modulus-36 checksum. All three steps together are about twenty lines and need no dependencies. The regex alone is not enough — it accepts roughly 36 wrong strings for every right one.
Yes — @dlminds/gstin-api. It is written in TypeScript, has zero runtime dependencies, ships both ESM and CommonJS builds with type declarations, targets Node 18 and above, and is MIT licensed. You can try it without installing anything: npx @dlminds/gstin-api explain 27AAACR5055K1Z7.
The offline half — checksum, format, state lookup, parsing — is isomorphic, imports nothing from node:, and tree-shakes, so it runs happily in a browser bundle. The API client is the part that must not: it needs a secret key, and any key shipped to a browser is a public key. Proxy lookups through your own server.
No. Store the normalised value — uppercase, with spaces and hyphens stripped. People paste GST numbers out of PDFs and emails with all sorts of decoration, and if you store the raw string your uniqueness constraints and joins quietly stop working. Normalise on the way in, once.
No. The algorithm is public, so a checksum-correct GSTIN takes seconds to fabricate, and a cancelled registration keeps its correct check digit forever. Offline validation proves the string is structurally possible; only a lookup against the government register proves the registration exists and is active.
No. It is written in TypeScript and ships declarations, so editors give you full completion either way, but the published package works unchanged from plain JavaScript with both import and require.

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 tool

Build it into your product

One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.

Start free