How-to 9 min read

GST Number Validation in Forms: React, Zod, Django, Laravel

Copy-paste GSTIN validators for the four stacks most Indian teams use, plus the input behaviour that stops users mistyping in the first place.

Written and reviewed by the GSTIN API team Last reviewed

Most GST number validation bugs are not validation bugs. They are input bugs — a field with maxlength="15" that truncates a pasted value before your code sees it, a keystroke handler that makes pasting impossible, a database holding the same registration twice because one row kept the hyphens.

So this article does the input behaviour first, then the error messages, then working validators for React and Zod, Django, Laravel, and plain JavaScript. The rule underneath all four is the same: normalise, then validate, then store the normalised form.

Fix the input before you validate it

A GSTIN is copied out of an invoice PDF, a WhatsApp message, or a supplier's email footer far more often than it is typed from memory. Whatever arrives in your field will have stray spaces, sometimes hyphens, and frequently the wrong case. Your job is to accept all of it and quietly clean it up.

AttributeValueWhy
maxlength25Not 15. A pasted 27-AAACR 5055 K1Z7 is 21 characters; a limit of 15 truncates it to 27-AAACR 5055 before any JavaScript runs, and the user sees a mangled value they did not type
autocapitalizecharactersPuts mobile keyboards in caps. Purely a keyboard hint — it does not transform the value, so you still normalise in code
autocompleteoffThere is no autofill token for a GSTIN, and browsers otherwise offer the user's name or postcode from an unrelated form
spellcheckfalseStops the red squiggle and, on mobile, stops autocorrect helpfully rewriting AAACR
inputmodeleave it aloneA GSTIN is letters and digits, so numeric gives a keypad that cannot type it. The default text keyboard is correct
patternthe GSTIN regexFree native validation on submit for the format — though never for the checksum. See the plain-HTML section
text-transformuppercase (CSS)Cosmetic only. The value in input.value is still whatever was typed, which is exactly why normalisation belongs in code and not in CSS
Every attribute here exists because of a specific failure we have watched happen.

Normalise on paste and on blur, not on every keystroke

The tempting implementation intercepts keydown and swallows anything that is not a letter or a digit. Do not do this. It breaks pasting with the keyboard, breaks input method editors, breaks browser autofill, and gives screen-reader users a field that silently discards their input. It also cannot be explained to the user — characters simply do not appear.

The behaviour that works: let people type anything, rewrite the value once on paste and once on blur, and only show a verdict once the normalised value has reached fifteen characters. Until then the field is neutral — no red border on a half-typed GSTIN.

// Uppercase, then drop anything that is not A-Z or 0-9: spaces, hyphens,
// non-breaking spaces from PDFs, and the zero-width characters that copying
// out of a web page loves to bring along.
export function normalizeGstin(value) {
  return String(value ?? '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}

normalizeGstin(' 27-AAACR 5055 K1Z7 ');   // '27AAACR5055K1Z7'

Error messages that tell the user what to do

“Invalid GST number” tells the user nothing. They can see it was rejected; what they need to know is which part is wrong so they know where to look. The validation you already ran knows the answer — the only thing standing between the user and it is a message string.

InputWhat went wrongMessage to show
(blank)Nothing enteredEmpty → “Enter the 15-character GST number.”
27AAACR5055K1ZOne character shortWrong length — a GSTIN is 15 characters, this is 14
2AAAACR5055K1Z7State code is not numericFirst two characters must be the numeric state code
27AAACR5055K1Y7Position 14 is not ZCharacter 14 must be the literal Z
27AAACR5055K0Z7Entity code 0 is never issuedCharacter pattern does not match the GSTIN format
27AAACR5055K1Z0Format fine, checksum wrongCheck digit mismatch — expected 7, found 0
The reason strings on the right are what gstinRejectionReason() returns verbatim.

That last row is the one that earns its keep. A checksum mismatch means the format is right and exactly one or two characters are wrong — nine times in ten a transposition or a single mistyped letter. Telling the user “expected 7, found 0” sends them straight to the last character instead of making them re-read all fifteen. The arithmetic behind that expectation is in the GSTIN checksum algorithm.

React and Zod

Zod's .transform() runs before your refinements, which is exactly the pipeline you want: normalise first, then judge the normalised value. Using .superRefine() rather than .refine() lets you emit the specific reason instead of a generic failure.

// gstinSchema.ts
import { z } from 'zod';
import { normalizeGstin, gstinRejectionReason } from '@dlminds/gstin-api';

export const gstinSchema = z
  .string()
  // Transform runs first, so every rule below sees the cleaned value —
  // and the parsed output is what you persist.
  .transform(normalizeGstin)
  .superRefine((gstin, ctx) => {
    const reason = gstinRejectionReason(gstin);   // string | null

    if (reason !== null) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, message: reason });
    }
  });

export const vendorSchema = z.object({
  name: z.string().min(2),
  gstin: gstinSchema,
});

vendorSchema.parse({ name: 'Acme', gstin: ' 27-aaacr 5055 k1z7 ' });
// { name: 'Acme', gstin: '27AAACR5055K1Z7' }

@dlminds/gstin-api is our MIT-licensed, zero-dependency package on npm — npm install @dlminds/gstin-api. If you would rather hand-roll the checksum, the twenty lines are in GSTIN validation in Node and TypeScript.

The controlled input

parseGstin returns the decoded fields alongside the verdict, which lets the field do something more useful than turning green: it can name the state and the entity type, so the user can confirm at a glance that they pasted the right supplier's number.

// GstinField.jsx
import { useMemo, useState } from 'react';
import { normalizeGstin, parseGstin } from '@dlminds/gstin-api';

export function GstinField({ value, onChange }) {
  const [touched, setTouched] = useState(false);
  const parsed = useMemo(() => parseGstin(value), [value]);

  // Stay silent while they are still typing — a half-entered GSTIN is not
  // an error yet. Show the verdict once it is 15 long, or once they leave.
  const showError = !parsed.valid && (touched || value.length >= 15);

  return (
    <div>
      <label htmlFor="gstin">GST number</label>
      <input
        id="gstin" value={value} maxLength={25} spellCheck={false}
        autoCapitalize="characters" autoComplete="off"
        aria-invalid={showError} aria-describedby="gstin-help"
        // Normalising on every change is fine — we are not *blocking*
        // anything. Every character the user typed survives; it is only
        // uppercased and stripped of punctuation.
        onChange={(event) => onChange(normalizeGstin(event.target.value))}
        onBlur={() => setTouched(true)}
      />
      <p id="gstin-help" role={showError ? 'alert' : undefined}>
        {showError ? parsed.reason
          : parsed.valid ? `${parsed.state} · ${parsed.panHolderType}`
          : '15 characters, as printed on the invoice'}
      </p>
    </div>
  );
}

Paste 29AAACI1195H1ZI into that field and the helper text reads Karnataka · Company before the form is ever submitted. That is worth more than a green tick: it catches the case where the number is valid but belongs to the wrong entity.

Django forms and models

Django's validator protocol is a callable that raises ValidationError, which slots the reason string straight into the form's field errors. Attach it to the model field so it also runs on full_clean(), admin saves, and Django REST Framework serialisers — not just on the one form you were thinking about.

# validators.py
from django.core.exceptions import ValidationError
from gstin_api import gstin_rejection_reason, normalize_gstin


def validate_gstin(value):
    """Raise with the specific reason, so the form field shows it verbatim."""
    reason = gstin_rejection_reason(value)

    if reason is not None:
        raise ValidationError(reason, code="invalid_gstin", params={"value": value})


# models.py
from django.db import models
from .validators import validate_gstin


class Vendor(models.Model):
    name = models.CharField(max_length=200)
    gstin = models.CharField(
        max_length=15,          # the *stored* value is always exactly 15
        unique=True,            # only safe because we normalise below
        validators=[validate_gstin],
    )

    def clean(self):
        # clean() runs before validators on full_clean(), so normalising here
        # means validate_gstin and the unique index both see the tidy value.
        self.gstin = normalize_gstin(self.gstin)
        super().clean()

    def save(self, *args, **kwargs):
        # .save() does not call full_clean(), and bulk imports and management
        # commands go straight here — so normalise again on the way out.
        self.gstin = normalize_gstin(self.gstin)
        super().save(*args, **kwargs)

pip install gstin-toolkit — the package is on PyPI, MIT-licensed with no dependencies, and exposes is_valid_gstin, gstin_rejection_reason, normalize_gstin and parse_gstin. Note the two names: the distribution is gstin-toolkit, the import is gstin_api. The internals are walked through in GSTIN validation in Python.

Laravel form requests

There is no Composer package for this yet, so put the algorithm in a small support class and call it from everywhere. Roughly thirty lines, no dependencies:

<?php
// app/Support/Gstin.php
namespace App\Support;

class Gstin
{
    private const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    private const PATTERN = '/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/';

    public static function normalize(?string $value): string
    {
        return preg_replace('/[^0-9A-Z]/', '', strtoupper((string) $value));
    }

    public static function checkDigit(string $first14): string
    {
        $sum = 0;

        foreach (str_split(substr($first14, 0, 14)) as $index => $character) {
            // Weights alternate 1, 2 starting at index 0.
            $product = strpos(self::CHARSET, $character) * ($index % 2 === 0 ? 1 : 2);
            $sum += intdiv($product, 36) + $product % 36;
        }

        return self::CHARSET[(36 - $sum % 36) % 36];
    }

    /** Null when valid, otherwise the reason — so callers can show it. */
    public static function rejectionReason(?string $value): ?string
    {
        $gstin = self::normalize($value);

        // Ordered most general to most specific, so the message names the
        // first thing that is actually wrong rather than a symptom of it.
        if ($gstin === '') return 'Empty';
        if (strlen($gstin) !== 15) return 'Wrong length — a GSTIN is 15 characters, this is '.strlen($gstin);
        if (! preg_match('/^[0-9]{2}/', $gstin)) return 'First two characters must be the numeric state code';
        if ($gstin[13] !== 'Z') return 'Character 14 must be the literal Z';
        if (! preg_match(self::PATTERN, $gstin)) return 'Character pattern does not match the GSTIN format';

        $expected = self::checkDigit($gstin);

        return $expected === $gstin[14]
            ? null
            : "Check digit mismatch — expected {$expected}, found {$gstin[14]}";
    }

    public static function isValid(?string $value): bool
    {
        return self::rejectionReason($value) === null;
    }
}

Then normalise in prepareForValidation() — before any rule runs — and validate with a closure rule that passes the reason through as the message:

<?php
// app/Http/Requests/StoreVendorRequest.php
namespace App\Http\Requests;

use App\Support\Gstin;
use Closure;
use Illuminate\Foundation\Http\FormRequest;

class StoreVendorRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        // Runs before the rules, so `unique:vendors,gstin` compares the
        // normalised value against the normalised column. Do this here and
        // you never have to think about casing again.
        $this->merge(['gstin' => Gstin::normalize($this->input('gstin'))]);
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:200'],
            'gstin' => [
                'required',
                'size:15',
                'unique:vendors,gstin',
                function (string $attribute, mixed $value, Closure $fail) {
                    $reason = Gstin::rejectionReason($value);

                    if ($reason !== null) {
                        $fail($reason);
                    }
                },
            ],
        ];
    }
}

If you use the same field on more than two or three forms, promote the closure to an invokable rule class (php artisan make:rule ValidGstin) with the identical body — the win is that 'gstin' => ['required', new ValidGstin] reads better and the message lives in one place. Either way, add a unique index on the column in the migration; only the index is authoritative under concurrency. Wiring the same field to the live register from a controller is covered in integrating the GST verification API in Laravel.

Plain HTML and JavaScript

No framework, no build step. The pattern attribute gives you native format validation for free — it is implicitly anchored, so no ^ or $ — and a small script adds the checksum that pattern cannot express.

<label for="gstin">GST number</label>
<input id="gstin" name="gstin" required maxlength="25" spellcheck="false"
       autocapitalize="characters" autocomplete="off"
       pattern="[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]"
       title="15 characters, for example 27AAACR5055K1Z7">
<p id="gstin-error" role="alert"></p>

pattern alone is not enough. It matches 27AAACR5055K1Z0 — correct shape, wrong check digit — because a regular expression cannot do arithmetic. Layer the checksum on with setCustomValidity, which keeps the browser's own error bubble and blocks submission for you:

 const field = document.getElementById('gstin');
const errorEl = document.getElementById('gstin-error');

function check(showWhileTyping) {
  // Rewrite the field itself so the pattern attribute and the submitted
  // value both see the cleaned string.
  field.value = normalizeGstin(field.value);

  const reason = gstinRejectionReason(field.value);   // your checksum code

  // An empty custom validity string means "valid" to the browser.
  field.setCustomValidity(reason ?? '');

  // Do not shout at a half-typed number.
  const visible = reason !== null && (showWhileTyping || field.value.length >= 15);
  errorEl.textContent = visible ? reason : '';
}

// Paste and blur are the two moments worth normalising on. Deliberately
// no keydown handler: intercepting keys breaks paste, IMEs and autofill.
field.addEventListener('paste', () => queueMicrotask(() => check(false)));
field.addEventListener('input', () => check(false));
field.addEventListener('blur', () => check(true));

The queueMicrotask on paste is not decoration: the paste event fires before the browser writes the pasted text into the field, so reading field.value synchronously gives you the old value. Deferring by a microtask reads the new one.

The full pattern, character class by character class, is pulled apart in the GSTIN regex explained. For a one-off check with no code at all, the free GSTIN validator runs entirely in your browser.

Client-side is UX, server-side is truth

Every validator above runs in a browser or a form layer, and every one of them can be skipped. A curl request, a mobile client on an old build, an internal admin script, a CSV import — none of them go anywhere near your React component.

LayerWhat it is forWhat it does not protect against
Input attributesGetting a clean value in the first placeAnything at all — they are hints
Client-side validationFast, specific feedback before a round tripAny request that did not come from your form
Server-side validationThe actual gate. Non-negotiableA well-formed GSTIN that was never issued
Database constraintUniqueness under concurrent writesValues inserted before you added the index
The same rule, applied at four layers, each catching what the one before it cannot.

The cost of the server-side check is a few microseconds of arithmetic on a fifteen-character string. There is no performance argument for skipping it, and no framework where it is inconvenient.

When the form should call the register

Everything so far proves a number could exist. It does not prove it does. The checksum algorithm is public — this site publishes it — so a valid-looking GSTIN is trivial to fabricate, and fake invoices routinely carry one. Only a lookup against the GST register returns the legal name, the registration date and the current status.

# Server-side only. The key must never reach the browser.
GET https://gstinapi.com/api/get-taxpayer-info/27AAACR5055K1Z7
X-API-Key: your_api_key_here

# Found when taxpayer_data.status_code === 1

Whether the form should verify live depends on what is behind it. A newsletter sign-up that happens to collect a GSTIN does not need it. A vendor onboarding form, where the number will end up on an input tax credit claim, absolutely does — the design of that flow is in GST verification for vendor onboarding, and the difference between the two checks in validation vs verification.

To recap: normalise on the way in, validate with the format and the checksum, say precisely what went wrong, validate again on the server, store the normalised value with a unique index, and call the register when the number actually matters. Field meanings are in GSTIN format explained, one-off lookups in GST number search, and the request and response reference in the API documentation — you can get a key in a couple of minutes.

Frequently asked questions

The pattern is ^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$ — two digits of state code, a ten-character PAN, an entity code that is never 0, a literal Z, and a check digit. It is necessary but not sufficient: a regex cannot compute the checksum, so it accepts impossible numbers such as 27AAACR5055K1Z0.
No. Intercepting keystrokes breaks pasting, breaks input method editors, breaks browser autofill, and breaks assistive technology. Let the user type or paste anything, then normalise the value on paste and on blur and show the verdict once the field has fifteen usable characters.
Not 15. People paste values like " 27-AAACR 5055 K1Z7 " straight out of invoices and PDFs, and a maxlength of 15 silently truncates the paste before your normalisation code ever sees it. Set it around 25 and strip the punctuation yourself.
Yes, always. Client-side validation is a convenience for honest users; anyone can POST to your endpoint directly with curl. The browser check exists to give fast feedback, not to protect your database — run the same validation again in your request handler.
No. Store the normalised, uppercase, punctuation-free form, and put a unique index on that column. If you store the raw input, "27AAACR5055K1Z7" and "27-aaacr 5055k1z7" become two different rows for the same registration, and every join and duplicate check downstream quietly breaks.
No. Format and checksum only prove the string is structurally possible — the algorithm is public, so a passing GSTIN is trivial to fabricate. Only a lookup against the GST register shows whether the number was issued, who holds it, and whether the registration is still active.

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