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.
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.
| Attribute | Value | Why |
|---|---|---|
maxlength | 25 | Not 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 |
autocapitalize | characters | Puts mobile keyboards in caps. Purely a keyboard hint — it does not transform the value, so you still normalise in code |
autocomplete | off | There is no autofill token for a GSTIN, and browsers otherwise offer the user's name or postcode from an unrelated form |
spellcheck | false | Stops the red squiggle and, on mobile, stops autocorrect helpfully rewriting AAACR |
inputmode | leave it alone | A GSTIN is letters and digits, so numeric gives a keypad that cannot type it. The default text keyboard is correct |
pattern | the GSTIN regex | Free native validation on submit for the format — though never for the checksum. See the plain-HTML section |
text-transform | uppercase (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 |
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.
| Input | What went wrong | Message to show |
|---|---|---|
(blank) | Nothing entered | Empty → “Enter the 15-character GST number.” |
27AAACR5055K1Z | One character short | Wrong length — a GSTIN is 15 characters, this is 14 |
2AAAACR5055K1Z7 | State code is not numeric | First two characters must be the numeric state code |
27AAACR5055K1Y7 | Position 14 is not Z | Character 14 must be the literal Z |
27AAACR5055K0Z7 | Entity code 0 is never issued | Character pattern does not match the GSTIN format |
27AAACR5055K1Z0 | Format fine, checksum wrong | Check digit mismatch — expected 7, found 0 |
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.
| Layer | What it is for | What it does not protect against |
|---|---|---|
| Input attributes | Getting a clean value in the first place | Anything at all — they are hints |
| Client-side validation | Fast, specific feedback before a round trip | Any request that did not come from your form |
| Server-side validation | The actual gate. Non-negotiable | A well-formed GSTIN that was never issued |
| Database constraint | Uniqueness under concurrent writes | Values inserted before you added the index |
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
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.