GST Verification in Django
Add GSTIN verification to a Django app - a model field validator, a service that calls the API, caching, and a form that checks on submit.
The generic Python walkthrough shows how to call a GST
verification API with requests. This post is about where that call belongs in a Django
project: offline validators on the model, one service module that owns the HTTP call and the cache, a
ModelForm that checks the GSTIN on submit, a fallback for when the government source is
unreachable, and a scheduled re-check so a supplier who was active in March is not still marked active
after cancellation in August. Every snippet uses a suppliers app. Rename it to fit yours.
Settings: the API key and the cache
The key comes from the environment and nowhere else. Reading it with os.environ[...] rather
than .get() makes a missing key fail at startup, not on the first supplier a user tries to
add. Keys are issued from the API keys page of your dashboard.
# settings.py
import os
GSTINAPI_KEY = os.environ["GSTINAPI_KEY"]
GSTINAPI_BASE_URL = "https://gstinapi.com/api/get-taxpayer-info/"
# Any backend works; Redis lets web and Celery workers share one cache.
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1"),
}
}
Use a shared cache backend in production. The default local-memory cache is per process, so a result cached by one Gunicorn worker is invisible to the next worker and to your Celery workers, and you pay for the same lookup several times.
The model field and its validators
A GSTIN is 15 characters: a two-digit state code, the holder’s PAN, an entity number, a literal
Z, and a check digit. Two validators cover everything you can know without a network call.
RegexValidator checks the shape. A small function checks the modulus-36 check digit,
computed exactly as described in the GSTIN checksum
algorithm.
# suppliers/validators.py
import re
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
GSTIN_PATTERN = r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$"
CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
gstin_format = RegexValidator(
GSTIN_PATTERN, message="Enter a 15-character GSTIN, e.g. 27ABCDE1234F1Z0."
)
def gstin_checksum(value):
if not re.fullmatch(GSTIN_PATTERN, value or ""):
return # the regex validator reports this one
total = 0
for i, ch in enumerate(value[:14]):
product = CHARS.index(ch) * (2 if i % 2 else 1)
total += product // 36 + product % 36
if value[14] != CHARS[(36 - total % 36) % 36]:
raise ValidationError("This GSTIN has a wrong check digit. Look for a typo.")
# suppliers/models.py
from django.db import models
from .validators import gstin_format, gstin_checksum
class Supplier(models.Model):
class Verification(models.TextChoices):
VERIFIED = "verified"
PENDING = "pending" # API was unreachable; re-check queued
FAILED = "failed" # not found, or not Active
name = models.CharField(max_length=200)
gstin = models.CharField(max_length=15, unique=True,
validators=[gstin_format, gstin_checksum])
legal_name = models.CharField(max_length=200, blank=True)
gst_status = models.CharField(max_length=40, blank=True)
verification = models.CharField(max_length=10, choices=Verification.choices,
default=Verification.PENDING)
verified_at = models.DateTimeField(null=True, blank=True)
Do not put the API call in a model validator. Validators run inside full_clean(), which
also runs in the admin, in scripts and in anything that validates before a bulk import. If you would
rather not maintain the checksum yourself, the Python package
(pip install gstin-toolkit, imported as gstin_api) ships an offline
is_valid_gstin. The trade-offs between offline libraries are covered in
GSTIN validation libraries for Python.
A service module that owns the HTTP call
One module holds the session, the retry policy, the cache and the response parsing. The single lookup
endpoint is GET /api/get-taxpayer-info/{gstin} with your key in the X-API-Key
header. The status codes that matter:
| Response | Meaning | Service behaviour |
|---|---|---|
200, taxpayer_data.status_code = 1 | Registration found; one credit charged | Return the result, cache it for a day |
200, taxpayer_data.status_code = 0 | No registration found; details in taxpayer_data.error; free | Return not-found, cache it for an hour |
| 401 | Key missing or wrong | Log an error and raise “unavailable”. Do not retry |
| 402 | Out of credits | Log an error and raise “unavailable”. Do not retry |
| 502 | Government source unreachable; free | Retry twice with backoff, then raise “unavailable” |
# suppliers/gstin_service.py
import logging
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.cache import cache
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
log = logging.getLogger(__name__)
FOUND_TTL = 60 * 60 * 24 # one day
NOT_FOUND_TTL = 60 * 60 # one hour
class GstinApiUnavailable(Exception):
"""Could not get an answer. Not the user's fault."""
@dataclass(frozen=True)
class GstinResult:
found: bool
legal_name: str = ""
trade_name: str = ""
status: str = ""
error_code: str = ""
@property
def is_active(self):
return self.found and self.status == "Active"
_retry = Retry(total=2, connect=0, read=0, status=2, backoff_factor=0.5,
status_forcelist=(502,), allowed_methods=frozenset({"GET"}),
raise_on_status=False)
_session = requests.Session()
_session.mount("https://", HTTPAdapter(max_retries=_retry))
_session.headers["X-API-Key"] = settings.GSTINAPI_KEY
def verify_gstin(gstin, use_cache=True):
key = f"gstin:v1:{gstin}"
if use_cache and (hit := cache.get(key)) is not None:
return hit
try:
resp = _session.get(settings.GSTINAPI_BASE_URL + gstin, timeout=(3, 10))
except requests.RequestException as exc:
raise GstinApiUnavailable(str(exc)) from exc
if resp.status_code in (401, 402):
log.error("GSTIN API rejected the request: HTTP %s", resp.status_code)
raise GstinApiUnavailable(f"HTTP {resp.status_code}")
if resp.status_code != 200:
raise GstinApiUnavailable(f"HTTP {resp.status_code}")
data = resp.json().get("taxpayer_data") or {}
if data.get("status_code") == 1:
result = GstinResult(found=True, legal_name=data.get("name", ""),
trade_name=data.get("tradename", ""),
status=data.get("status", ""))
cache.set(key, result, FOUND_TTL)
else:
err = data.get("error") or {}
result = GstinResult(found=False, error_code=err.get("error_cd", ""))
cache.set(key, result, NOT_FOUND_TTL)
return result
Three decisions in there are deliberate. connect=0, read=0 means a timeout goes straight
to the fallback instead of making a user wait through three of them on a form submit. The
v1 in the cache key lets you invalidate every entry by bumping it when you change
GstinResult. And the legal name comes from taxpayer_data.name, the trade name
from taxpayer_data.tradename. There is no top-level legal_name on this endpoint.
Why the two names differ, and which one to match against, is in
legal name vs trade name.
Checking the GSTIN in ModelForm.clean_gstin
In a ModelForm, the model’s validators run after clean_gstin, in
_post_clean. So clean_gstin normalises the input, runs the two offline
validators itself (a typo should never cost a network call), and only then calls the service.
# suppliers/forms.py
from django import forms
from django.db import transaction
from django.utils import timezone
from .gstin_service import GstinApiUnavailable, verify_gstin
from .models import Supplier
from .validators import gstin_checksum, gstin_format
class SupplierForm(forms.ModelForm):
class Meta:
model = Supplier
fields = ["name", "gstin"]
def clean_gstin(self):
gstin = self.cleaned_data["gstin"].strip().upper()
gstin_format(gstin)
gstin_checksum(gstin)
try:
result = verify_gstin(gstin)
except GstinApiUnavailable:
self.instance.verification = Supplier.Verification.PENDING
return gstin
if not result.found:
raise forms.ValidationError("No GST registration exists for this GSTIN.")
if not result.is_active:
raise forms.ValidationError(
f"This GSTIN is registered but its status is {result.status}.")
self.instance.legal_name = result.legal_name
self.instance.gst_status = result.status
self.instance.verification = Supplier.Verification.VERIFIED
self.instance.verified_at = timezone.now()
return gstin
def save(self, commit=True):
supplier = super().save(commit)
if commit and supplier.verification == Supplier.Verification.PENDING:
from .tasks import recheck_supplier
transaction.on_commit(lambda: recheck_supplier.delay(supplier.pk))
return supplier
Rejecting anything other than Active is a business choice. For onboarding a supplier you
will pay, it is the right default. For a customer you are invoicing, you may prefer to accept and flag.
When the API is down: accept, then re-check
The verification data comes from the government’s systems, which do go down, often near
filing deadlines. If your form blocks on that, onboarding stops for reasons the user cannot fix. The form
above accepts a GSTIN that passed the offline checks, marks it pending, and queues a
re-check once the database transaction commits. Using on_commit matters: queue the task
inside the transaction and a fast worker can look for a row that does not exist yet.
What pending means downstream is your call. A sensible rule is that a pending supplier can
be created and quoted, but a payment run skips it until the re-check turns it to verified.
Periodic re-verification with Celery or a management command
A GSTIN that was active at onboarding can be suspended or cancelled later, and the checksum never
changes when that happens. Re-checks call the service with use_cache=False so they see
the current register. If you run Celery, the task also retries pending rows with backoff:
# suppliers/tasks.py
from celery import shared_task
from django.utils import timezone
from .gstin_service import GstinApiUnavailable, verify_gstin
from .models import Supplier
@shared_task(autoretry_for=(GstinApiUnavailable,), retry_backoff=60,
retry_backoff_max=3600, max_retries=6)
def recheck_supplier(pk):
supplier = Supplier.objects.get(pk=pk)
result = verify_gstin(supplier.gstin, use_cache=False)
supplier.legal_name = result.legal_name
supplier.gst_status = result.status
supplier.verification = (Supplier.Verification.VERIFIED if result.is_active
else Supplier.Verification.FAILED)
supplier.verified_at = timezone.now()
supplier.save(update_fields=["legal_name", "gst_status",
"verification", "verified_at"])
Without Celery, a management command run from cron does the same job:
# suppliers/management/commands/reverify_gstins.py
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from suppliers.models import Supplier
from suppliers.tasks import recheck_supplier
class Command(BaseCommand):
help = "Re-verify pending GSTINs and any not checked in N days."
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=30)
def handle(self, *args, days, **options):
cutoff = timezone.now() - timedelta(days=days)
stale = Supplier.objects.filter(
Q(verification=Supplier.Verification.PENDING)
| Q(verified_at__lt=cutoff) | Q(verified_at__isnull=True))
for pk in stale.values_list("pk", flat=True).iterator():
recheck_supplier(pk) # call directly; use .delay() with Celery
Calling the task function directly runs it inline, and a GstinApiUnavailable will stop
the loop. For a large supplier table, wrap each call in a try/except and pace the loop, as covered in
rate limits and retries. Each re-check of a live
GSTIN is a billable verification (₹0.50 pay-as-you-go, ₹0.40 once a single purchase crosses 2,500
credits; see pricing), so choose --days to match how much a stale
status costs you. Monthly is enough for most supplier masters. Before a large payment run is a good
extra trigger.
Testing it with unittest.mock
Tests should never call the real API. Patch the module-level session, give each test a local-memory
cache so cached results cannot leak between tests, and use a synthetic GSTIN with a correct check digit.
27ABCDE1234F1Z0 passes the checksum. Why a hand-typed fixture usually does not is explained
in test GSTIN numbers for testing.
# suppliers/tests/test_gstin.py
from unittest import mock
import requests
from django.test import TestCase, override_settings
from suppliers.forms import SupplierForm
from suppliers.models import Supplier
GSTIN = "27ABCDE1234F1Z0"
LOCMEM = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
def api_response(taxpayer_data, status=200):
resp = mock.Mock(status_code=status)
resp.json.return_value = {"taxpayer_data": taxpayer_data, "filing_data": []}
return resp
@override_settings(CACHES=LOCMEM)
@mock.patch("suppliers.gstin_service._session.get")
class SupplierFormTests(TestCase):
def test_active_gstin_is_verified(self, get):
get.return_value = api_response({"status_code": 1, "gstin": GSTIN,
"name": "EXAMPLE TRADERS PRIVATE LIMITED",
"tradename": "Example Traders",
"status": "Active"})
form = SupplierForm(data={"name": "Example", "gstin": GSTIN.lower()})
self.assertTrue(form.is_valid(), form.errors)
supplier = form.save()
self.assertEqual(supplier.verification, Supplier.Verification.VERIFIED)
self.assertEqual(supplier.legal_name, "EXAMPLE TRADERS PRIVATE LIMITED")
def test_not_found_is_rejected(self, get):
get.return_value = api_response({"status_code": 0,
"error": {"error_cd": "SWEB_9035", "message": "Invalid GSTIN / UID"}})
form = SupplierForm(data={"name": "Example", "gstin": GSTIN})
self.assertFalse(form.is_valid())
def test_cancelled_is_rejected(self, get):
get.return_value = api_response({"status_code": 1, "name": "X",
"status": "Cancelled"})
self.assertFalse(SupplierForm(data={"name": "X", "gstin": GSTIN}).is_valid())
def test_bad_checksum_never_calls_api(self, get):
form = SupplierForm(data={"name": "X", "gstin": "27ABCDE1234F1Z5"})
self.assertFalse(form.is_valid())
get.assert_not_called()
@mock.patch("suppliers.tasks.recheck_supplier.delay")
def test_outage_saves_pending_and_queues(self, delay, get):
get.side_effect = requests.ConnectionError("down")
form = SupplierForm(data={"name": "X", "gstin": GSTIN})
self.assertTrue(form.is_valid(), form.errors)
with self.captureOnCommitCallbacks(execute=True):
supplier = form.save()
self.assertEqual(supplier.verification, Supplier.Verification.PENDING)
delay.assert_called_once_with(supplier.pk)
Patching _session.get bypasses the retry adapter, which is what you want in unit tests.
captureOnCommitCallbacks is needed because TestCase wraps each test in a
transaction that never commits, so on_commit callbacks would otherwise never fire. When you
want to see the real response shape before writing more fixtures, the API docs
show the full taxpayer_data, filing_data and nc_data objects.
Frequently asked questions
A note on accuracy. GST rules change often. This article reflects our understanding as of 23 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.