To verify a GSTIN programmatically, send a POST to /v1/verify/gstin with the fifteen-character number and, if you have it, the business name you expect. The response tells you whether the registration exists (valid), under which legal and trade name, in what state of registration (registrationStatus) and at which principal place of business. A status of Active is the pass condition. Anything else should stop the flow or send it to a person.
The call is the easy part. The hard parts are the ones around it: validating the number before you spend a call, deciding what each status means for your business, comparing names without rejecting good vendors, confirming the GSTIN belongs to the PAN you already trust, checking again later because registrations change, and running thousands of checks without tripping rate limits. This guide builds each of those pieces with working code against the StampMitra endpoints.
It assumes you know what a GSTIN is. If not, PAN vs GSTIN vs CIN explains the structure first, and the older GSTIN verification API guide covers supplier onboarding and invoice risk from the finance side.
Key takeaways
- One endpoint,
POST /v1/verify/gstin, scopeverification.gstin.read. Required fieldgstin; optionalbusinessName. Authenticate withAuthorization: Bearer <key>orx-api-key. - Format failures return 400 before any lookup and cost nothing. Negative results (
valid: false) are completed lookups that are billed in production. - The response carries
valid,legalNameOfBusiness,tradeNameOfBusiness,gstinStatus,taxpayerType,constitutionOfBusiness,dateOfRegistration, the jurisdictions,natureOfBusinessActivities, the principal and additional addresses (as a string and split into parts),message, the number ingstinandGSTIN, and our two ids,referenceIdandverificationId. The earlier nameslegalName,tradeName,registrationStatusandprincipalPlaceOfBusinesscarry the same values. - Treat
registrationStatusas an open set. The documentation showsActive; other statuses exist at source and are not enumerated. Pass onActive, review everything else, and log the raw value. - The response does not carry the effective date of a cancellation or the filing history. If your decision depends on those, you need another source or a human.
- Compare the vendor's name to
legalName, nottradeName, and compare the PAN embedded in the GSTIN to the PAN you verified. - A GSTIN is a point-in-time fact. Re-check on a schedule and before large payments.
- For bulk checks, cap concurrency and add backoff on 429. The documented example limit is 60 requests per 60 seconds for an endpoint.
What you need before the first call
| Item | Value |
|---|---|
| Base URL | https://api-b2b.stampmitra.in |
| Path | POST /v1/verify/gstin |
| Auth | Authorization: Bearer <key> or x-api-key: <key> |
| Scope on the key | verification.gstin.read (a key without it gets 403 FORBIDDEN) |
| Body | gstin (required, 15 characters), businessName (optional) |
| Billing | Metered as gstin.verify after a successful response; sandbox never debits the wallet |
| Sync or async | Synchronous. The answer is in the HTTP response. |
| Sandbox key | Starts with sk_test_; production key starts with sk_live_ |
A sandbox key takes about five minutes to get through the quickstart. Keep the key on your server. Never send it from a browser or mobile app.
Where the call belongs in your system
A GSTIN check looks like a utility function, but where you put it shapes reliability. Three placements are common, and each suits a different product.
Inline in the request. The user types a GSTIN into a form and waits for a green tick. This gives instant feedback and is right for short flows where a rejected number is fixed on the spot. The cost is that your page's latency now includes a third-party call, so set a firm timeout (ten seconds is a reasonable ceiling), show a progress state and design the form so a timeout does not lose what the user typed. Never let the browser call StampMitra directly, because that would put the key in the page. The browser calls your server, and your server calls the API.
In a background job. The vendor submits the form, you acknowledge it, and a worker runs the checks. This suits flows with several checks (PAN, GSTIN, bank account) because you can order them, stop on the first hard failure and retry transient errors without a user waiting. Persist each result against the application as it arrives. A job that crashes between the GSTIN and the bank account check should resume from where it stopped, not start again and pay twice.
As a scheduled sweep. The re-check job above, which touches every vendor on a timer. It has no user to notify, so it needs its own alerting: a flag on the vendor, a ticket, or an email to the procurement owner.
Whichever you choose, keep the call behind one small module. Everything in this guide (input normalisation, error mapping, decision function) lives there, so the rest of the codebase asks "is this GSTIN acceptable for this vendor?" and gets PASS, REVIEW or FAIL with reasons, never raw HTTP.
One more design point: the verification endpoints are not idempotent in the sense of de-duplicating repeated calls. The referenceId identifies each call, not a request you can safely repeat for free. If your job runner retries a task after a crash, make the job itself idempotent by recording that the check was done before you issue it again.
Step 1: normalise and validate before you call
Trim whitespace and upper-case the input before anything else. The documentation states the format as 07AAAAA1111A1Z1 and does not promise case-insensitive handling, so do not rely on it. Then run three offline checks, in this order of cost and usefulness: the shape, the state code, and the check character.
// gstin-input.mjs
const SHAPE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
const CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const STATE_CODES = new Set([
"01","02","03","04","05","06","07","08","09","10","11","12","13","14","15","16","17","18","19","20",
"21","22","23","24","26","27","29","30","31","32","33","34","35","36","37","38","97","99",
]);
function checkChar(first14) {
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = CHARSET.indexOf(first14[i]) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
return CHARSET[(36 - (sum % 36)) % 36];
}
export function normaliseGstin(raw, { production }) {
const gstin = String(raw ?? "").replace(/\s+/g, "").toUpperCase();
if (!SHAPE.test(gstin)) return { ok: false, reason: "FORMAT" };
if (!STATE_CODES.has(gstin.slice(0, 2))) return { ok: false, reason: "STATE_CODE" };
if (production && checkChar(gstin.slice(0, 14)) !== gstin[14]) return { ok: false, reason: "CHECK_CHARACTER" };
return { ok: true, gstin, stateCode: gstin.slice(0, 2), embeddedPan: gstin.slice(2, 12) };
}The production flag matters. The sample GSTIN in the documentation and the documented sandbox GSTINs do not carry a valid check character, so a check-character test will reject them. Pass production: key.startsWith("sk_live_").
Each reason maps to a user message. FORMAT says the number must be fifteen letters and digits. STATE_CODE says the first two digits are not a valid state or union territory code. CHECK_CHARACTER says "please check the last character". All three catch typos for free.
Step 2: call the endpoint
The raw request is small.
curl -X POST "https://api-b2b.stampmitra.in/v1/verify/gstin" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"gstin":"07AAAAA1111A1Z1","businessName":"Jane Doe Enterprises"}'A successful response, in the documented shape:
{
"referenceId": "SMV-20260927-9K21P4",
"GSTIN": "07AAAAA1111A1Z1",
"valid": true,
"legalName": "Jane Doe Enterprises Private Limited",
"tradeName": "Jane Doe Enterprises",
"registrationStatus": "Active",
"principalPlaceOfBusiness": "123, MG Road, Bengaluru, Karnataka, 560001",
"message": "GSTIN Exists"
}A typed Node.js wrapper with a timeout, error mapping and the offline validation in front:
// gstin-client.mjs
import { normaliseGstin } from "./gstin-input.mjs";
const BASE = "https://api-b2b.stampmitra.in/v1/verify/gstin";
export class GstinError extends Error {
constructor(kind, detail = {}) { super(kind); this.kind = kind; Object.assign(this, detail); }
}
export async function verifyGstin({ gstin, businessName, apiKey = process.env.STAMPMITRA_API_KEY }) {
const production = apiKey?.startsWith("sk_live_");
const input = normaliseGstin(gstin, { production });
if (!input.ok) throw new GstinError("INVALID_INPUT", { reason: input.reason });
const res = await fetch(BASE, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ gstin: input.gstin, ...(businessName ? { businessName } : {}) }),
signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GstinError("API_ERROR", { status: res.status, code: body.error?.code, requestId: body.error?.requestId });
}
return { ...body, embeddedPan: input.embeddedPan, stateCode: input.stateCode };
}And the same in Python with requests:
import os, re, requests
BASE = "https://api-b2b.stampmitra.in/v1/verify/gstin"
SHAPE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
class GstinError(Exception):
def __init__(self, kind, **detail):
super().__init__(kind)
self.kind, self.detail = kind, detail
def verify_gstin(gstin: str, business_name: str | None = None, api_key: str | None = None) -> dict:
api_key = api_key or os.environ["STAMPMITRA_API_KEY"]
g = "".join((gstin or "").split()).upper()
if not SHAPE.match(g):
raise GstinError("INVALID_INPUT", reason="FORMAT")
payload = {"gstin": g, **({"businessName": business_name} if business_name else {})}
r = requests.post(BASE, json=payload, timeout=10,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"})
body = r.json() if r.content else {}
if not r.ok:
err = body.get("error", {})
raise GstinError("API_ERROR", status=r.status_code, code=err.get("code"), request_id=err.get("requestId"))
return {**body, "embedded_pan": g[2:12], "state_code": g[:2]}If you prefer a package over raw HTTP, the pre-release SDKs wrap the same endpoint. The behaviour below does not change.
The response, field by field
| Field | Type | Meaning | How to use it |
|---|---|---|---|
referenceId | string | StampMitra's id for this call, form SMV-YYYYMMDD-XXXXXXX, present even on negative results | Store it with your decision. It is not an idempotency key. |
GSTIN | string | The number that was checked (note the upper-case field name) | Compare with what you sent |
valid | boolean | Whether a registration with this number was found | Branch on this first |
legalName | string | Legal name on the registration | Compare against the entity's registered name |
tradeName | string | Trade or brand name | Context, not an identity check |
registrationStatus | string | State of the registration at source | The decision field; see the next section |
principalPlaceOfBusiness | string | Principal place of business address | Context for address checks; not a proof of where goods ship from |
message | string | Human-readable summary such as GSTIN Exists | Display or log it; do not parse it for logic |
Field naming is worth a note. Most StampMitra verification responses use camel case, while the GSTIN number echoes under GSTIN. Read the exact key from the reference rather than normalising it by habit.
What the status values mean
The reference shows one example value, Active, and does not list the others. At source, a GST registration can be in more than one state, so write your logic for an open set. The table covers the states people meet most often and what a sensible integration does with each. Treat the meanings as general information and confirm anything with tax consequences with your advisor.
registrationStatus | General meaning | Typical handling |
|---|---|---|
Active | The registration is in force | Pass, subject to the name and PAN checks |
| Cancelled | The registration has been cancelled, either on the taxpayer's application or by the tax authority | Block for new GST invoicing. Cancellations can be revoked later, so a re-check can change the answer |
| Suspended | Registration is temporarily suspended, typically while a cancellation proceeding is pending | Hold. Do not onboard for GST-based transactions until resolved |
| Anything else, or an empty value | A state your code does not recognise | Review. Log the raw value and the referenceId, and add it to your table once you understand it |
Three clarifications.
First, valid: false and a non-active status are different outcomes. valid: false means the lookup did not find a registration matching the number: a typo that passed format validation, a number that was never issued, or a failure to match. A non-active registrationStatus with valid: true means the registration exists and is in a state you must interpret. Handle them on different branches, with different messages to the user.
Second, the response gives you the status but not when it changed. A cancellation date can matter for whether invoices raised before it are still good for input tax credit purposes. This response does not carry that date, so a Cancelled result is a reason to stop and ask a person, not to try to reason about dates in code.
Third, the response does not include the return filing status or the effective date of a cancellation. If your policy depends on either, you need another source or a document from the vendor. The taxpayer type, which tells you whether a registration is under the composition scheme, and the constitution of business are in the response as taxpayerType and constitutionOfBusiness; read them rather than inferring them from the legal name.
A decision function you can reuse
Keep the mapping from response to decision in one pure function so that it is easy to test and easy to change.
// gstin-decision.mjs
const norm = (s) => String(s ?? "").toUpperCase().replace(/[.,&()-]/g, " ").replace(/\b(PRIVATE|PVT)\b/g, "PVT").replace(/\b(LIMITED|LTD)\b/g, "LTD").replace(/\s+/g, " ").trim();
export function decideGstin(res, { expectedPan, expectedName } = {}) {
const reasons = [];
if (!res.valid) return { decision: "FAIL", reasons: ["NOT_FOUND"], referenceId: res.referenceId };
if (res.registrationStatus !== "Active") reasons.push(`STATUS_${String(res.registrationStatus || "EMPTY").toUpperCase()}`);
if (expectedPan && res.embeddedPan && res.embeddedPan !== expectedPan) reasons.push("PAN_MISMATCH");
if (expectedName && norm(res.legalName) !== norm(expectedName)) reasons.push("NAME_DIFFERS");
if (reasons.includes("PAN_MISMATCH")) return { decision: "FAIL", reasons, referenceId: res.referenceId };
if (reasons.length) return { decision: "REVIEW", reasons, referenceId: res.referenceId };
return { decision: "PASS", reasons, referenceId: res.referenceId };
}The shape is deliberate. A PAN_MISMATCH is a hard fail, because a GSTIN that embeds a different PAN than the entity you verified is not that entity's registration. Status and name differences go to review rather than reject, because they have innocent explanations. The function returns reasons as codes, not sentences, so your review queue can group and count them.
Five outcomes, worked through
Reading a decision function is easier with cases. These use invented businesses and the documented response shape.
1. A clean match. The vendor says Jane Doe Enterprises Private Limited, with a verified PAN and a GSTIN that embeds that same PAN. The response has valid: true, registrationStatus: Active and a legalName of Jane Doe Enterprises Private Limited. The embedded PAN equals the verified PAN, and after normalisation the names agree. Decision: PASS. Store the referenceId, the status and the time.
2. A cancelled registration. The vendor sends 09SNDBX9999F1Z5, the sandbox value that is documented to return not valid with a Cancelled status. The reference does not say whether a real cancelled registration comes back as valid: false or as valid: true with a Cancelled status, so your code should handle both: any non-active status, and any valid: false, stops the vendor short of approval. Decision: REVIEW with a status reason, or FAIL with NOT_FOUND. The message to the vendor is neutral: "We could not confirm an active GST registration for this number. Please send a current registration certificate or confirm the number." Do not accuse. Cancellations are sometimes reversed, and the vendor may have a pending revocation.
3. A GSTIN that embeds someone else's PAN. The vendor's verified PAN is ABCDE1234F. They quote a GSTIN whose characters 3 to 12 are a different PAN, and the lookup returns valid: true and Active for that other business. Decision: FAIL with PAN_MISMATCH. This is the case that name comparison might miss if the other business has a similar name, and exactly why the PAN comparison exists. Ask the vendor which PAN the registration was made under before doing anything else.
4. A name that differs in a harmless way. The vendor's registered name is "Sharma and Sons Traders". The legalName returned is "SHARMA & SONS TRADERS". After normalisation they match. Without normalisation, a strict comparison would reject a perfectly good vendor. Decision: PASS.
5. A number that was mistyped. The vendor's GSTIN has one character wrong. If the check character is wrong, your offline validation catches it before any call and shows "please check the last character". If the typo happens to produce another structurally valid number, the lookup returns valid: false, or returns a different business entirely. In the second case the embedded PAN comparison is again what saves you. Decision for valid: false: FAIL with NOT_FOUND, and a user message that asks them to re-enter the number, not one that says the vendor is unregistered.
Notice what the five cases have in common: every decision carries reasons as codes, and every user-facing message avoids assigning blame.
What to tell the user for each outcome
Developers write error messages as an afterthought, and vendors read them as an accusation. Prepare wording for each branch in advance.
| Decision | Reason | Suggested message |
|---|---|---|
INVALID_INPUT | Format, state code or check character | "That does not look like a valid GSTIN. It has 15 letters and digits. Please check what you typed." |
FAIL | NOT_FOUND | "We could not find a registration for this number. Please re-enter it or send your registration certificate." |
REVIEW | Status not active | "We could not confirm an active registration yet. Our team will contact you, or you can upload a current certificate." |
REVIEW | Name differs | "The name on the registration is slightly different from the one you entered. Please confirm your legal name." |
FAIL | PAN mismatch | "This GSTIN is registered against a different PAN than the one you gave. Please check both numbers." |
| Error | Timeout or 502 | "We could not reach the verification service. Please try again in a few minutes." |
The last row matters most for trust. A timeout or an upstream error says nothing about the vendor. Never present it as a verification failure.
What the GST portal shows that the API does not
The public GST portal's taxpayer search can show more than the StampMitra response returns. Depending on the registration, the portal can show the date of cancellation where one exists and the return filing status, neither of which the API response carries. The registration date, the constitution of business, the taxpayer type and the jurisdictions are in the response (dateOfRegistration, constitutionOfBusiness, taxpayerType, centerJurisdiction, stateJurisdiction).
This matters in two ways. First, do not promise your compliance team a field the response does not have. If your policy needs the cancellation date or the filing status, say so up front and decide whether a human will look it up on the portal or ask the vendor for a certificate. Second, when a reviewer opens the portal and sees a richer record than your tool shows, that is not a discrepancy, it is a difference in scope. The separate question of why the same field can differ between the API and the portal is covered in why API results differ from the government portal.
Comparing names without rejecting good vendors
The optional businessName field asks the platform to compare, but you should also compare on your side, because you need to control the policy. The pitfalls are predictable.
- Compare against
legalName. For a sole proprietor the PAN name and thelegalNameagree, while thetradeNameis the shop's brand. A comparison with the trade name fails for good vendors. - Normalise before comparing: case, punctuation, "Private Limited" versus "Pvt Ltd", "and" versus "&", repeated spaces. The
normfunction above shows the idea; extend it with your own list of equivalences. - Decide whether a different name is a failure or a review. For vendors who are known to rename (acquisitions, conversions from partnership to company), review is kinder and safer.
- Do not run fuzzy comparison on identifiers. Names can be approximate. PANs and GSTINs must match exactly.
Does the GSTIN belong to the PAN you trust?
Characters 3 to 12 of any GSTIN are a PAN. If you have already verified the entity's PAN, compare the two without any extra call. This single comparison catches the common abuse of quoting somebody else's real, active GSTIN. The decideGstin function above does it through embeddedPan.
The order of operations follows from that. Verify the PAN first, because it is the anchor, then verify the GSTIN and check the embedded PAN against it. Reversing the order means you may spend a GSTIN call on a vendor whose PAN would have failed. The guide on how to verify a PAN and match the name covers the first half.
Finding GSTINs from a PAN
Sometimes you have the PAN and need the GSTINs, for instance a vendor who says "we are GST registered" without telling you which state. POST /v1/verify/pan-to-gstin takes a pan and returns the associated registrations. It needs the scope verification.pan_to_gstin.read and is metered as pan.to_gstin.
curl -X POST "https://api-b2b.stampmitra.in/v1/verify/pan-to-gstin" \
-H "Authorization: Bearer $STAMPMITRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"pan":"ABCDE1234F"}'{
"referenceId": "SMV-20260927-1L67Z5",
"found": true,
"gstins": [
{ "gstin": "07AAAAA1111A1Z1", "state": "Delhi", "status": "Active" }
]
}| Field | Meaning |
|---|---|
found | Whether any GSTIN is associated with the PAN |
gstins[].gstin | A registration number |
gstins[].state | The state of the registration |
gstins[].status | The registration status as returned in this list |
Use the lookup to pick the right GSTIN for an invoice's place of supply, to confirm that the number a vendor quotes is in the list, or to discover that a vendor with a PAN has no registration at all (found: false). Then verify the chosen GSTIN with the main endpoint if you need its legal name, trade name and principal place of business. The list shows state and status but not names.
Multiple GSTINs and which one to use
A national vendor holds one GSTIN per state, so the list from the PAN lookup can be long. Choose by purpose.
| Purpose | Which GSTIN | Why |
|---|---|---|
| Paying an invoice | The one printed on the invoice | The invoice is the document you will claim against |
| Checking a vendor exists | Any active one, plus the embedded PAN | The PAN is the identity; the GSTIN is a registration |
| Deciding place of supply | The one whose state matches the supply | State code is the first two digits |
| Monitoring | All of them | A single cancelled registration may affect only one state |
Never assume the first GSTIN in the list is the head office. The list is a set of registrations, not a hierarchy.
Checking again later
A GSTIN that was active at onboarding can be suspended or cancelled afterwards. Schedule re-checks, tied to how much you depend on the registration.
| Vendor tier | Example re-check rule |
|---|---|
| High spend or regulated exposure | Before each payment run above a threshold, and monthly |
| Regular | Quarterly |
| Occasional | On next transaction after twelve months |
A minimal Python job that walks your vendor table and flags changes:
import time
from your_app import vendors, save_check, raise_flag
from gstin_client import verify_gstin, GstinError
def recheck_all(batch_size=30, pause_seconds=60):
queue = list(vendors.due_for_recheck())
for i in range(0, len(queue), batch_size):
for v in queue[i:i + batch_size]:
try:
res = verify_gstin(v.gstin)
except GstinError as e:
save_check(v.id, outcome="error", detail=e.detail)
continue
changed = res["registrationStatus"] != v.last_status
save_check(v.id, outcome="ok", reference_id=res["referenceId"], status=res["registrationStatus"])
if changed:
raise_flag(v.id, f"status {v.last_status!r} -> {res['registrationStatus']!r}")
time.sleep(pause_seconds) # stay well inside the per-endpoint rate limitEach re-check is a billable call in production with its own referenceId. Re-check by risk, not by habit.
Bulk verification without hitting the rate limit
The documented 429 example reads "Rate limit exceeded: 60 requests per 60 seconds for this endpoint." Your plan's actual limit depends on the plan (the sandbox plan, pay-as-you-go and the higher plans differ), so read your own limit from the dashboard instead of hard-coding the example. Design for it.
// bulk-gstin.mjs
import { verifyGstin, GstinError } from "./gstin-client.mjs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function verifyMany(items, { concurrency = 4, perMinute = 50 } = {}) {
const results = new Array(items.length);
const gap = Math.ceil(60_000 / perMinute);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
for (let attempt = 0; attempt < 3; attempt++) {
try {
results[i] = { ok: true, data: await verifyGstin(items[i]) };
break;
} catch (e) {
if (e instanceof GstinError && e.status === 429 && attempt < 2) { await sleep(2000 * 2 ** attempt + Math.random() * 500); continue; }
results[i] = { ok: false, error: e.kind ?? "UNKNOWN", status: e.status, requestId: e.requestId };
break;
}
}
await sleep(gap * concurrency);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
}The pacing line is the important one: each worker sleeps long enough that the combined rate stays under perMinute. Retry only 429 (and, if you wish, 502) with growing, jittered waits and a hard cap on attempts. Never retry a 400, 401, 402 or 403. The guide on errors, retries and idempotency goes through why, including the fact that the verification endpoints document no idempotency key.
Caching and freshness
It is tempting to cache GSTIN responses to save calls. A short cache for the same GSTIN within one onboarding session is sensible, since the user may resubmit a form. A long cache is a risk, because the whole point of checking is to see the current state. If you cache, store the referenceId and the timestamp with the result, expire it by the re-check rules above, and never serve a cached Active into a payment decision that is older than your policy allows. Also remember that the response reflects the data source as of its last refresh, not necessarily the instant of your call; see why results differ from the government portal.
Testing the integration
The documented sandbox GSTINs give you both outcomes on demand: 09SNDBX0000A1Z5 returns valid and Active, and 09SNDBX9999F1Z5 returns not valid and Cancelled. Any other value gets a stable but arbitrary result. Sandbox never verifies a real registration, so it tests your code rather than your vendors. A short test list:
| Case | Input | Assert |
|---|---|---|
| Happy path | 09SNDBX0000A1Z5 | valid true, registrationStatus Active, referenceId matches SMV-\d{8}- |
| Negative result | 09SNDBX9999F1Z5 | HTTP 200, valid false, no exception from your error path |
| Format error | 12345 | Rejected offline; and if sent, 400 with BAD_REQUEST |
| Wrong key | A made-up key | 401 UNAUTHORIZED |
| Missing scope | A key without the GSTIN scope | 403 FORBIDDEN |
| Unknown status | A fixture with registrationStatus: "Weird" | REVIEW with a STATUS_WEIRD reason |
| PAN mismatch | A fixture whose embedded PAN differs | FAIL with PAN_MISMATCH |
For what the sandbox can and cannot tell you, and the real go-live steps, read sandbox vs production: what you can test.
Logging and privacy
A GSTIN of a company is business information, but for a sole proprietor the legal name is a person's name and the principal place of business may be a home address. Log the referenceId, the decision, the status and the time. Mask the GSTIN to its first two and last four characters in application logs if you do not need the full number. Do not log full response bodies by default. The guide on what to log from a verification call lists what to keep and for how long.
The manual review step
Every integration of this kind ends up with a review queue, so design it deliberately instead of letting it become an inbox. A reviewer handling a GSTIN case needs a single screen with: the vendor and what they claimed (name, PAN, GSTIN, state, address); the verification result with its referenceId and timestamp; the reason codes that put the case in the queue; the PAN verification result next to it for comparison; and any documents the vendor has uploaded, such as a registration certificate.
The reviewer's decisions should be a short, closed list: approve, reject, request a document, request a corrected number, escalate. Each decision records who made it, when and why, in one line of free text. The list is closed so you can report on it later: how many cases are rejected for PAN mismatch, how many are approved after a document is supplied, and how long each takes.
Give reviewers a way to re-run the check. A vendor who says "we reinstated the registration last week" should be re-verified, not argued with. The re-run is a new billable call with a new referenceId, and it replaces the earlier result on the vendor's record while leaving the original in the audit history.
Finally, decide the service level for the queue. A vendor waiting a week for a human to look at a name difference will go elsewhere. A queue with a daily owner and a clear escalation path costs less than the vendors it saves.
What to monitor once it is live
A verification integration fails quietly. It rarely crashes. It starts returning a different mix of results, or the wallet runs out, or a key is revoked, and nobody notices until onboarding stalls. A few metrics make those failures visible.
| Metric | Why it matters | A sensible alert |
|---|---|---|
| Calls per hour by outcome (pass, review, fail, error) | A shift in the mix is the first sign of a data, policy or integration change | Review share or error share moves well outside its usual band |
| Error count by status and code | Separates 401 and 403 (key problems), 402 (wallet), 429 (rate), 5xx (upstream) | Any 401, 403 or 402; sustained 429 or 502 |
| Latency of the call, median and the slow tail | A slow upstream shows here before it shows as errors | Tail latency above your timeout minus a margin |
| Wallet balance and burn rate | A 402 stops onboarding | Balance below the number of days of calls you are comfortable with |
| Review queue age | A growing queue means lost vendors | Oldest case beyond your service level |
| Re-check flags raised | Shows how often registrations change under you | Sudden spike, which may indicate a data change |
Two practices make these metrics trustworthy. Count outcomes by your own decision codes rather than by HTTP status alone, since a valid: false is a 200 but still a notable event. And log the requestId on every error, so that when something does look wrong you can hand specific requests to support rather than describing a pattern.
A readiness checklist for this integration
Before you point production traffic at the GSTIN check, confirm each line.
- The key lives in a secret manager, starts with
sk_live_in production and has only the scopes the service needs (verification.gstin.read, plusverification.pan_to_gstin.readif you use the lookup). - Input is trimmed and upper-cased, and the offline checks run with the check character enabled only for live keys.
- Every call has a timeout, and timeouts and 502s are surfaced as "try again", never as a verification failure.
- 400, 401, 402 and 403 are not retried. 429 is retried with jittered backoff and a cap.
- Unknown statuses go to review and are logged raw.
- The PAN embedded in the GSTIN is compared with the verified PAN before a vendor is approved.
- Names are compared with
legalNameafter normalisation. - The
referenceId, decision, reasons and timestamp are stored for every call, and nothing else sensitive is logged by default. - A re-check schedule exists and an owner receives the flags it raises.
- The wallet has a low-balance alert in your own monitoring, so a 402 does not stop onboarding unnoticed.
- A sandbox test run covers every row of the test table above and refuses to run with a live key.
- A production smoke test with your own company's GSTIN has been run and its result reviewed.
Common mistakes
- Treating
valid: trueas "approved". The status, the PAN and the name still have to agree. - Comparing names with
tradeName. - Hard-coding a list of statuses and rejecting anything outside it silently. Unknown means review.
- Running the check-character test in sandbox.
- Verifying only the first GSTIN a vendor mentions.
- Calling the API from the browser, which exposes the key.
- Retrying on 400 or 402 in a loop.
- Caching an
Activeresult far beyond the period your policy accepts. - Ignoring
referenceId, which leaves no link between your decision and the call.
What to do next
Get a sandbox key through the quickstart, run the cURL call above with 09SNDBX0000A1Z5, and then read the GSTIN endpoint reference and the PAN to GSTIN reference. For the full vendor process these checks belong to, read Building a vendor onboarding flow. Product details and pricing for the endpoint are on the GSTIN product page and the pricing page. A GSTIN call is ₹5 on pay-as-you-go and lower on plans. Questions go to support.
API reference at a glance
GSTIN
POST /v1/verify/gstinRequest parameters
| Field | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer <api-key> (e.g. sk_test_... in sandbox, sk_live_... in production). The x-api-key header is also accepted. |
gstinrequired | string | 15-character GSTIN, format 07AAAAA1111A1Z1. |
businessName | string | Business (legal or trade) name to cross-check against the registration. |
POST /v1/verify/gstin{
"referenceId": "SMV-20260927-9K21P4",
"verificationId": "VRF-4M8Q2T7XB9KD",
"gstin": "29AAICP2912R1ZR",
"GSTIN": "29AAICP2912R1ZR",
"valid": true,
"legalNameOfBusiness": "UJJIVAN SMALL FINANCE BANK LIMITED",
"tradeNameOfBusiness": "UJJIVAN SMALL FINANCE BANK",
"centerJurisdiction": "I-A RANGE",
"stateJurisdiction": "GUWAHATI - A - 1",
"dateOfRegistration": "2017-09-30",
"constitutionOfBusiness": "Public Limited Company",
"taxpayerType": "Regular",
"gstinStatus": "Active",
"lastUpdateDate": "2022-03-01",
"natureOfBusinessActivities": [
"Retail Business",
"Supplier of Services",
"Recipient of Goods or Services",
"Office / Sale Office",
"Others"
],
"principalPlaceAddress": "First Floor 3512-DISPUR Prithivi Mansion opp. KFC building G.S. Road, Lachit Nagar Assam 781007",
"principalPlaceSplitAddress": {
"buildingName": "Prithivi Mansion",
"street": "G.S. Road",
"location": "Lachit Nagar",
"buildingNumber": "3512-DISPUR",
"district": "Guwahati",
"state": "Assam",
"city": "Dispur",
"flatNumber": "First Floor",
"latitude": "26.1445",
"longitude": "91.7362",
"pincode": "781007"
},
"additionalAddresses": [
{
"address": "Mirza Santipur NH-37 Dist-Kamrup Rural PS-Palashbari, PO Mirza Kamrup Assam 781125",
"splitAddress": {
"buildingName": "",
"street": "NH-37",
"location": "Santipur, Mirza",
"buildingNumber": "",
"district": "Kamrup Rural",
"state": "Assam",
"city": "Palashbari",
"flatNumber": "",
"latitude": "26.0926",
"longitude": "91.5069",
"pincode": "781125"
}
},
{
"address": "N.A.C.C.I. Foundation 3559-Tezpur CHAMBER BHAWAN Binjraj Road, P. o. and P.S. - Tezpur, Dist. Binjraj Road, P. o. and P.S. - Tezpur, Dist. Sonitpur Assam 784154",
"splitAddress": {
"buildingName": "CHAMBER BHAWAN",
"street": "Binjraj Road",
"location": "Tezpur",
"buildingNumber": "3559-Tezpur",
"district": "Sonitpur",
"state": "Assam",
"city": "Tezpur",
"flatNumber": "",
"latitude": "26.6338",
"longitude": "92.8006",
"pincode": "784154"
}
}
],
"message": "GSTIN Exists",
"legalName": "UJJIVAN SMALL FINANCE BANK LIMITED",
"tradeName": "UJJIVAN SMALL FINANCE BANK",
"registrationStatus": "Active",
"principalPlaceOfBusiness": "First Floor 3512-DISPUR Prithivi Mansion opp. KFC building G.S. Road, Lachit Nagar Assam 781007"
}POST /v1/verify/gstinGSTIN: ₹5 per successful call, pay-as-you-go (lower on Growth and Scale).
PAN to GSTIN
POST /v1/verify/pan-to-gstinRequest parameters
| Field | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer <api-key> (e.g. sk_test_... in sandbox, sk_live_... in production). The x-api-key header is also accepted. |
panrequired | string | PAN to resolve to associated GSTIN(s). |
POST /v1/verify/pan-to-gstin{
"referenceId": "SMV-20260927-1L67Z5",
"found": true,
"gstins": [
{
"gstin": "07AAAAA1111A1Z1",
"state": "Delhi",
"status": "Active"
}
]
}POST /v1/verify/pan-to-gstinPAN to GSTIN: ₹5 per successful call, pay-as-you-go (lower on Growth and Scale).
Frequently asked questions
How do I verify a GSTIN with an API?
Send a POST request to /v1/verify/gstin with a gstin field and an optional businessName, authenticated with a Bearer key that has the verification.gstin.read scope. The response returns valid, legalNameOfBusiness, tradeNameOfBusiness, gstinStatus, taxpayerType, constitutionOfBusiness, dates, jurisdictions, business activities, the principal and additional addresses, a message, and our referenceId and verificationId. The earlier field names legalName, tradeName, registrationStatus and principalPlaceOfBusiness carry the same values.
What does registrationStatus Active mean?
It means the registration is in force at source, which is the pass condition for onboarding. Other statuses such as cancelled or suspended exist at source and the reference does not list them all, so route anything other than Active to review and log the raw value.
What is the difference between valid false and a non-active status?
Valid false means the lookup did not find a registration for the number, for example a typo that still passed format validation. A non-active status with valid true means the registration exists but is not in force. Handle them on separate branches with different user messages.
Does the response show when a registration was cancelled?
No. The response carries the status, the taxpayer type, the constitution of business, the registration and last-update dates and the jurisdictions, but not the cancellation date or the filing history. If your decision depends on those, use another source or ask the vendor for a certificate.
Which name should I compare with the vendor's name?
Compare it with legalName, not tradeName. For a sole proprietor the PAN name and the legal name agree while the trade name is the shop brand. Normalise case, punctuation and suffixes such as Private Limited and Pvt Ltd before comparing.
How do I check the GSTIN belongs to the vendor's PAN?
Characters 3 to 12 of a GSTIN are a PAN. Compare them with the PAN you already verified, with no extra call. A mismatch means the registration belongs to a different taxpayer and should fail the check.
Should I run the GSTIN check character test in sandbox?
No. The sample GSTIN in the docs and the documented sandbox GSTINs do not carry a valid check character. Enable that offline check only when the key starts with sk_live_, and keep the shape and state code checks on everywhere.
Can I find a vendor's GSTINs from their PAN?
Yes. POST /v1/verify/pan-to-gstin with a pan returns found and a list of gstins with state and status, using the verification.pan_to_gstin.read scope. Then verify the GSTIN you need with the main endpoint to get names and the principal place of business.
How should I handle rate limits when verifying many GSTINs?
Cap concurrency, pace requests below your plan's limit, and retry only 429 and 502 with jittered backoff and a hard attempt limit. The documented example limit is 60 requests per 60 seconds for an endpoint, but read your own limit from the dashboard.
How often should I re-check a GSTIN?
Re-check by risk. High-spend vendors before payment runs above a threshold and monthly, regular vendors quarterly, occasional ones on the next transaction after a year. Registrations can be suspended or cancelled after onboarding, and each re-check is a new billable call.
Sources
- GST portal, taxpayer registration and search information: https://www.gst.gov.in (confirm current registration statuses and their meaning there, 2026).
- StampMitra API reference data for
/v1/verify/gstinand/v1/verify/pan-to-gstin: fields, errors, scopes and billing, as in this repository on 2026-10-29. - StampMitra sandbox test values, copied from the sandbox responder, 2026-10-29.
- StampMitra offline GSTIN validation logic (format, state code, check character) in this repository, mirroring the server's validation, 2026-10-29.