How to Integrate a GST Verification API in Node.js
Verify GSTINs from an Express or Node.js backend using fetch, with error handling and a reusable wrapper function.
Integrating a GST verification API in Node.js takes one fetch call and one header. The work that
actually matters is everything around it: keeping the key off the client, rejecting malformed GSTINs before
they cost you a credit, timing out cleanly, and telling the three kinds of failure apart.
Everything below uses the built-in global fetch available since Node 18 — no axios, no SDK, no
dependencies at all.
Keep the key on the server
This is the one mistake worth calling out before any code. A GST verification API is authenticated with a secret. If you call it from React, Vue, or any browser code, that secret goes to every visitor.
# .env — loaded with `node --env-file=.env` on Node 20+, or dotenv
GSTIN_API_KEY=your_api_key_here
GSTIN_API_BASE_URL=https://gstinapi.com/api
Validate the GSTIN before you call out
A GSTIN is 15 characters and its final character is a check digit computed from the first 14. If the checksum does not agree, the number cannot be a real registration — so there is no reason to spend a request on it. This runs in microseconds and removes most typos.
// gstin.js
const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PATTERN = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
export function normalise(value) {
return String(value ?? '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}
export function checkDigitFor(first14) {
let sum = 0;
for (let i = 0; i < 14; i++) {
const value = CHARSET.indexOf(first14[i]);
if (value === -1) return null;
// Weights alternate 1, 2 across the 14 characters.
const product = value * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
return CHARSET[(36 - (sum % 36)) % 36];
}
export function isValidGstin(value) {
const gstin = normalise(value);
if (!PATTERN.test(gstin)) return false;
return checkDigitFor(gstin.slice(0, 14)) === gstin[14];
}
Check it against a real number: isValidGstin('27AAACR5055K1Z7') is true, and
changing any single character makes it false. The full breakdown of what each position means is
in GSTIN format explained.
A reusable client with timeouts and retries
// gstinClient.js
import { isValidGstin, normalise } from './gstin.js';
const BASE_URL = process.env.GSTIN_API_BASE_URL ?? 'https://gstinapi.com/api';
const API_KEY = process.env.GSTIN_API_KEY;
const TIMEOUT_MS = 12_000;
export class GstinLookupError extends Error {
constructor(message, { status, retryable }) {
super(message);
this.name = 'GstinLookupError';
this.status = status;
this.retryable = retryable;
}
}
async function requestOnce(gstin) {
const response = await fetch(`${BASE_URL}/get-taxpayer-info/${gstin}`, {
headers: { 'X-API-Key': API_KEY },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (response.status === 429 || response.status >= 500) {
throw new GstinLookupError(`Upstream returned ${response.status}`, {
status: response.status,
retryable: true,
});
}
if (!response.ok) {
throw new GstinLookupError(`Request rejected with ${response.status}`, {
status: response.status,
retryable: false,
});
}
return response.json();
}
export async function verifyGstin(input, { retries = 2 } = {}) {
const gstin = normalise(input);
// Reject the impossible locally — this costs nothing and saves a credit.
if (!isValidGstin(gstin)) {
return { gstin, outcome: 'invalid_format' };
}
for (let attempt = 0; ; attempt++) {
try {
const payload = await requestOnce(gstin);
const taxpayer = payload.taxpayer_data ?? {};
if (taxpayer.status_code !== 1) {
return { gstin, outcome: 'not_found' };
}
return {
gstin,
outcome: 'ok',
legalName: taxpayer.name,
tradeName: taxpayer.tradename,
status: taxpayer.status,
registeredOn: taxpayer.registrationDate,
constitution: taxpayer.constitution,
isActive: taxpayer.status === 'Active',
};
} catch (error) {
const retryable = error.name === 'TimeoutError' || error.retryable;
if (!retryable || attempt >= retries) {
return { gstin, outcome: 'upstream_error', error };
}
// Exponential backoff with jitter, so a burst of retries does not
// arrive in lockstep after an outage.
const delay = 2 ** attempt * 300 + Math.random() * 200;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Wire it into an Express route
import express from 'express';
import { verifyGstin } from './gstinClient.js';
const app = express();
app.use(express.json());
app.post('/api/vendors', async (req, res) => {
const result = await verifyGstin(req.body.gstin);
switch (result.outcome) {
case 'invalid_format':
return res.status(422).json({ error: 'That is not a valid GSTIN.' });
case 'not_found':
return res.status(422).json({ error: 'No GST registration exists for that number.' });
case 'upstream_error':
// Our problem, not the vendor's — do not reject them for it.
req.log?.error(result.error);
return res.status(503).json({ error: 'Verification is temporarily unavailable.' });
}
if (!result.isActive) {
return res.status(422).json({ error: `That GSTIN is ${result.status.toLowerCase()}.` });
}
// Store the name the government returned, not the one the user typed.
const vendor = await db.vendors.create({
gstin: result.gstin,
legalName: result.legalName,
verifiedAt: new Date(),
});
res.status(201).json(vendor);
});
Storing result.legalName rather than whatever the user typed is the highest-value line in that
handler. It is what later lets you spot an invoice whose name does not match the registration. See
GST verification for vendor onboarding.
Distinguish the three kinds of failure
| Outcome | Cause | Correct response |
|---|---|---|
invalid_format | Typo — checksum cannot match | 422, ask them to re-enter |
not_found | Well-formed but never issued | 422, and treat as a fraud signal |
| Active false | Cancelled or suspended registration | 422, flag for manual review |
upstream_error | Timeout, 429, or 5xx | 503, retry later — never reject the vendor |
Collapsing these into one generic error is the most common bug in a verification integration: you end up turning away good customers because your own provider had a bad minute. The complete status list is in the API error codes guide.
Typing the response
// types.ts
export type GstinOutcome = 'ok' | 'invalid_format' | 'not_found' | 'upstream_error';
export interface GstinFailure {
gstin: string;
outcome: Exclude<GstinOutcome, 'ok'>;
error?: unknown;
}
export interface GstinSuccess {
gstin: string;
outcome: 'ok';
legalName: string;
tradeName?: string;
status: 'Active' | 'Cancelled' | 'Suspended' | string;
registeredOn: string;
constitution: string;
isActive: boolean;
}
export type GstinResult = GstinSuccess | GstinFailure;
// A discriminated union means TypeScript forces you to handle the failures
// before it will let you read legalName.
export function isVerified(result: GstinResult): result is GstinSuccess {
return result.outcome === 'ok';
}
Verifying many GSTINs with bounded concurrency
Promise.all over a thousand vendors fires a thousand simultaneous requests, which will rate-limit
you and may exhaust sockets. A small pool is barely more code and behaves predictably.
export async function verifyAll(gstins, { concurrency = 5 } = {}) {
const queue = [...gstins];
const results = [];
async function worker() {
while (queue.length > 0) {
const gstin = queue.shift();
results.push(await verifyGstin(gstin));
}
}
// Start N workers that each drain the shared queue.
await Promise.all(
Array.from({ length: Math.min(concurrency, gstins.length) }, worker),
);
return results;
}
Dedupe the input first — vendor masters are full of repeats, and each duplicate is a wasted credit. The wider pipeline design is covered in the bulk GSTIN verification guide, and the backoff maths in rate limits, retries and webhooks.
Testing without hitting the network
Node's built-in test runner and mock support are enough — no extra libraries:
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { verifyGstin } from './gstinClient.js';
test('never calls the network for a checksum-invalid GSTIN', async () => {
const fetchMock = mock.method(globalThis, 'fetch');
const result = await verifyGstin('27AAACR5055K1Z8');
assert.equal(result.outcome, 'invalid_format');
assert.equal(fetchMock.mock.callCount(), 0);
});
test('maps an active registration', async () => {
mock.method(globalThis, 'fetch', async () => new Response(
JSON.stringify({
taxpayer_data: { status_code: 1, status: 'Active', name: 'ACME PRIVATE LIMITED' },
}),
{ status: 200 },
));
const result = await verifyGstin('27AAACR5055K1Z7');
assert.equal(result.isActive, true);
assert.equal(result.legalName, 'ACME PRIVATE LIMITED');
});
That is a complete Node.js integration: a checksum guard, a client with timeouts and backoff, an Express handler that distinguishes the failure modes, types, a bounded bulk pool, and tests. The same structure translates to Python, Go, and Laravel. Full request and response reference in the API documentation.
Frequently asked questions
A note on accuracy. GST rules change often. This article reflects our understanding as of 29 August 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.