Guide 9 min read

How to Verify Thousands of Vendor GSTINs in One Batch

Submit up to 10,000 GSTINs as one queued job and get a row back for every row you sent. What each outcome costs, with a 5,000-row vendor master costed.

Written and reviewed by the GSTIN API team Last reviewed

A vendor master is the usual reason someone needs this. Procurement has been adding suppliers for six years, nobody ever re-checked the GST numbers, and now finance wants to know which of them are still registered before the next payment run. The file has 5,000 rows, some of them are duplicates, a few are obviously mistyped, and you need an answer for every one.

You can do that with one request. This guide covers the batch endpoint of the GST verification API — how to submit a list, what you are charged for, and what happens when something goes wrong halfway through.

Why a loop stops being the right answer

For a few dozen GSTINs, calling the single-lookup endpoint in a loop is genuinely fine, and we would rather you did that than reach for machinery you do not need. The difficulty is not that loops are inelegant. It is that a loop over 5,000 rows quietly acquires four jobs that have nothing to do with GST:

  • Pacing. Fire 5,000 requests as fast as your event loop allows and you will be throttled. Pace them too conservatively and the run takes all afternoon.
  • Resumption. A process that dies on row 3,200 has to know that, and know not to re-verify the first 3,199 — which you have already paid for.
  • Deduplication. The same GSTIN appearing eleven times in a vendor master is normal. Eleven lookups for it is eleven charges for one fact.
  • Pre-validation. A number that cannot be a GSTIN should never cost a request. That check is arithmetic you can run offline, and a loop that skips it pays to be told what it could have worked out itself.

None of that is hard. It is just four things to get right, per integration, and they are the four things that decide the bill. The batch endpoint does them server-side.

The shape of a batch job

Batch verification is asynchronous by design. A list of any size is accepted in milliseconds and worked through by queue workers at a pace we control — which is the only way to offer this without letting one large customer decide how much traffic the GST network receives on everyone else's behalf. The cost to you is one extra round trip: submit, then poll.

POST   /api/bulk/jobs                   → 202, returns a job_id
GET    /api/bulk/jobs/{uuid}            → status and progress
GET    /api/bulk/jobs/{uuid}/results    → one row per submitted entry
GET    /api/bulk/jobs/{uuid}/results?format=csv
DELETE /api/bulk/jobs/{uuid}            → cancel; verified rows are kept

Every call takes your X-API-Key header. Submit either a gstins array or a text blob — the latter accepts a pasted column from a spreadsheet and splits it for you.

Submitting

curl -X POST https://gstinapi.com/api/bulk/jobs \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Vendor master Q3",
        "gstins": ["27AAACR5055K1Z7", "29AAACR5055K1Z5", "06AAACR5055K1ZG"]
      }'

The response is 202 Accepted with the job and a set of links, so you do not have to build the follow-up URLs yourself:

{
  "success": true,
  "job": {
    "job_id": "9b2f1c4e-...",
    "status": "queued",
    "stopped_reason": null,
    "progress": { "percent": 0, "total_rows": 3, "total_lookups": 3, "completed_lookups": 0 },
    "results": { "succeeded": 0, "not_found": 0, "invalid": 0, "failed": 0, "skipped": 0 },
    "credits_used": 0
  },
  "links": {
    "self":    "https://gstinapi.com/api/bulk/jobs/9b2f1c4e-...",
    "results": "https://gstinapi.com/api/bulk/jobs/9b2f1c4e-.../results",
    "csv":     "https://gstinapi.com/api/bulk/jobs/9b2f1c4e-.../results?format=csv"
  }
}

Polling and collecting

Poll links.self until status reaches a terminal value — completed, cancelled or failed. Two seconds between polls is plenty; the status endpoint is the cheapest one on the API precisely so you can poll it without thinking about it.

Working code

Three complete implementations of submit → poll → collect. They use plain HTTP with no dependencies beyond what each language ships with, so you can paste them into an existing codebase without adding anything.

Node.js

const API = 'https://gstinapi.com';
const headers = { 'X-API-Key': process.env.GSTINAPI_API_KEY, 'Content-Type': 'application/json' };

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function verifyBatch(gstins, name) {
  const submit = await fetch(`${API}/api/bulk/jobs`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ gstins, name }),
  });

  // Read the body once — a Response can only be consumed a single time.
  const payload = await submit.json();
  if (!submit.ok) throw new Error(`Submit failed: ${payload.message}`);

  const { links } = payload;

  // Poll until the job reaches a terminal state.
  let current = payload.job;
  while (!['completed', 'cancelled', 'failed'].includes(current.status)) {
    await sleep(2000);
    const poll = await fetch(links.self, { headers });
    current = (await poll.json()).job;
  }

  if (current.stopped_reason === 'insufficient_credits') {
    console.warn(`Stopped early: ${current.progress.completed_lookups} of ${current.progress.total_lookups} verified.`);
  }

  // Results are paginated, one row per GSTIN you submitted, in submission order.
  const rows = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`${links.results}?page=${page}&per_page=1000`, { headers });
    const body = await res.json();
    rows.push(...body.results);
    if (page >= body.pagination.last_page) break;
  }

  return { job: current, rows };
}

Python

import os, time, requests

API = "https://gstinapi.com"
HEADERS = {"X-API-Key": os.environ["GSTINAPI_API_KEY"]}
TERMINAL = {"completed", "cancelled", "failed"}

def verify_batch(gstins, name=None):
    submit = requests.post(
        f"{API}/api/bulk/jobs",
        headers=HEADERS,
        json={"gstins": list(gstins), "name": name},
        timeout=30,
    )
    submit.raise_for_status()
    payload = submit.json()
    job, links = payload["job"], payload["links"]

    while job["status"] not in TERMINAL:
        time.sleep(2)
        job = requests.get(links["self"], headers=HEADERS, timeout=30).json()["job"]

    if job["stopped_reason"] == "insufficient_credits":
        done, total = job["progress"]["completed_lookups"], job["progress"]["total_lookups"]
        print(f"Stopped early: {done} of {total} verified. Top up and resubmit the rest.")

    rows, page = [], 1
    while True:
        body = requests.get(
            links["results"],
            headers=HEADERS,
            params={"page": page, "per_page": 1000},
            timeout=60,
        ).json()
        rows.extend(body["results"])
        if page >= body["pagination"]["last_page"]:
            break
        page += 1

    return job, rows

PHP / Laravel

use Illuminate\Support\Facades\Http;

function verifyBatch(array $gstins, ?string $name = null): array
{
    $api = Http::withHeaders(['X-API-Key' => config('services.gstinapi.key')])
        ->timeout(30);

    $payload = $api->post('https://gstinapi.com/api/bulk/jobs', [
        'gstins' => $gstins,
        'name' => $name,
    ])->throw()->json();

    $job = $payload['job'];
    $links = $payload['links'];

    while (! in_array($job['status'], ['completed', 'cancelled', 'failed'], true)) {
        sleep(2);
        $job = $api->get($links['self'])->throw()->json('job');
    }

    if ($job['stopped_reason'] === 'insufficient_credits') {
        report(new RuntimeException(
            "Bulk job stopped early: {$job['progress']['completed_lookups']} of {$job['progress']['total_lookups']}."
        ));
    }

    $rows = [];
    $page = 1;

    do {
        $body = $api->get($links['results'], ['page' => $page, 'per_page' => 1000])->throw()->json();
        $rows = array_merge($rows, $body['results']);
        $page++;
    } while ($page <= $body['pagination']['last_page']);

    return [$job, $rows];
}

What you are actually charged for

This is the part worth reading closely, because it is where a batch differs most from a loop. Every submitted row resolves to exactly one outcome, and only one of those outcomes costs a credit.

OutcomeWhat it meansCharged
okA live registration was returned1 credit
not_foundWell-formed, but no such registration existsFree
invalid_formatRejected offline — never sent to the GST networkFree
upstream_errorThe GST network failed, after a retryFree
skippedNot reached — the job was cancelled or ran out of creditFree
Row outcomes and what each one costs

Two details follow from this that are easy to miss:

  • Duplicates cost once. Submit the same GSTIN eleven times and it is looked up once and charged once — but you still get eleven result rows, in the positions you submitted them, so the output still lines up with your input file.
  • The tallies count distinct numbers, not rows. succeeded, not_found, invalid, failed and skipped are counted once per distinct GSTIN, so on a list with duplicates they will not add up to the number of rows you get back. That is deliberate — it keeps the tallies in the same unit as the bill.

A 5,000-row vendor master, costed

Take a realistic file: 5,000 rows, with the repetition and mess that a six-year-old vendor master actually contains.

StageCountCharged
Rows submitted5,000—
Exact repeats, collapsed300Free
Distinct numbers to resolve4,700—
Malformed — rejected offline150Free
Sent to the GST network4,550—
Not registered90Free
Upstream error after retry15Free
Verified successfully4,4454,445 credits
Worked example — 5,000 submitted rows

At ₹0.40 per credit — the rate once a single purchase crosses 2,500 credits — that file costs ₹1,778, and you get 5,000 result rows back in the order you submitted them. The 555 rows that did not resolve to a live registration cost nothing, and you are told precisely which of them were typos, which were genuinely unregistered, and which failed for reasons that were never your fault.

Limits

LimitValue
GSTINs per job10,000
Unfinished jobs per account3
Result rows per page1,000
Job submissions20 per minute
Status polls600 per minute
Result and listing calls120 per minute
Batch verification limits

A list longer than 10,000 is split across jobs. Because three can be unfinished at once, a 30,000-row file can be in flight in its entirety — submit three, and start a fourth as each one lands.

If you would rather not write code

Upload the CSV in your dashboard. You get your own file back with verification columns appended — legal name, trade name, status, taxpayer type, constitution, registration and cancellation dates, jurisdiction, principal address, nature of business, e-invoice status — with your original columns and row order untouched. That last part matters more than it sounds: a results file that has been re-sorted is a file somebody has to reconcile by hand.

The same billing applies. You are charged for rows that resolve to a live registration, and nothing else.

Which one should you use?

SituationUse
Verifying one vendor during onboarding, user is waitingSingle lookup
A handful of numbers in an ad-hoc scriptSingle lookup, in a loop
A vendor master, customer list or procurement exportBatch job
A nightly re-check of everyone you payBatch job
A list with unknown quality and probable duplicatesBatch job
Single lookup vs batch job

Both read the same live GST records through the same GSP network. The difference is who handles pacing, deduplication and resumption — you, or us.

Frequently asked questions

Up to 10,000 GSTINs per job, and you can hold three unfinished jobs per account at once. A larger list is split across several jobs. There is no charge for submitting a job — you are billed per verified row as the job runs.
No. Only a lookup that returns a live registration is charged. Malformed numbers are rejected offline and never reach the GST network, unregistered numbers return not_found, and rows that hit a transport error after retrying are all free. Duplicate entries are looked up and charged once, however many times you submitted them.
The job stops rather than overdrawing your account. Every row verified before that point keeps its result, and the job reports stopped_reason: insufficient_credits. Top up and submit the remaining rows as a new job — you are never charged twice for a row that already resolved.
Submission returns in milliseconds; the verification itself is paced deliberately. A batch is the right tool when you care that all of the rows finish, and a direct call is the right tool when you need one answer inside a request the user is waiting on. Both read the same live GST records.
Yes. Upload a CSV in the dashboard and you get your own spreadsheet back with verification columns appended, so the row order and any columns you already had are preserved.

A note on accuracy. GST rules change often. This article reflects our understanding as of 15 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