Integration guide 6 min read

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.

Written and reviewed by the GSTIN API team Last reviewed

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

OutcomeCauseCorrect response
invalid_formatTypo — checksum cannot match422, ask them to re-enter
not_foundWell-formed but never issued422, and treat as a fraud signal
Active falseCancelled or suspended registration422, flag for manual review
upstream_errorTimeout, 429, or 5xx503, retry later — never reject the vendor
Only the first two rows say anything about the counterparty.

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

No. Doing so ships your API key to every visitor, who can then read it out of the network tab and spend your credits. Always proxy the call through your own Node backend and keep the key in an environment variable on the server.
Fetch is enough. Node has had a global fetch since version 18, so for a single GET request with one header there is no reason to add a dependency. AbortSignal.timeout gives you the timeout handling that used to be the main argument for axios.
Pass an AbortSignal with a timeout to fetch, and wrap the call so a timeout produces a distinct result rather than an unhandled rejection. Ten to fifteen seconds is a sensible ceiling for a verification call.
Never Promise.all over the whole array — that fires every request at once. Use a small worker pool, typically four to eight concurrent requests, so you stay inside rate limits and get predictable memory use.
Yes. GST registration status changes rarely, so a Redis or in-memory cache keyed by GSTIN with a few hours of TTL removes most repeat calls. Cache successes only — caching a timeout turns a blip into hours of wrong answers.

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