Integration guide 9 min read

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.

Written and reviewed by the GSTIN API team Last reviewed

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:

ResponseMeaningService behaviour
200, taxpayer_data.status_code = 1Registration found; one credit chargedReturn the result, cache it for a day
200, taxpayer_data.status_code = 0No registration found; details in taxpayer_data.error; freeReturn not-found, cache it for an hour
401Key missing or wrongLog an error and raise “unavailable”. Do not retry
402Out of creditsLog an error and raise “unavailable”. Do not retry
502Government source unreachable; freeRetry twice with backoff, then raise “unavailable”
What the single GSTIN lookup can return, and what the Django service does with each.
# 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

In a plain service module, not in the model, the view or a signal. The form calls the service to reject a bad GSTIN, and the Celery task and management command call the same function for re-checks, so the HTTP, caching and error handling are written once and tested once.
Model validators run inside full_clean(), and that runs in admin saves, data migrations, fixtures and bulk scripts. A network call there makes every one of those slow and dependent on an outside service. Keep model validators offline (format and checksum) and make the live lookup an explicit step.
A day is a reasonable default for a live registration, since GST status does not change hour to hour and a repeat lookup costs a credit. Cache a not-found result for a shorter period, such as an hour, so a GSTIN that was just issued is not rejected all day. Never cache an upstream failure.
No. The single lookup endpoint returns HTTP 200 whether or not the GSTIN exists. You have to read taxpayer_data.status_code: 1 means a registration was found and one credit was charged, 0 means no registration was found and the error details are in taxpayer_data.error. The not-found case is free.
Usually not. If the GSTIN passes the offline format and checksum checks, save it with a pending status and queue a re-check. Blocking onboarding because a government system is briefly unreachable costs you more than holding one payment until the re-check clears.
Yes. The package is installed with pip install gstin-toolkit and imported as gstin_api, and it includes an offline is_valid_gstin check. This post uses requests directly so every piece of the Django integration, including retries and caching, is visible and under your control.

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