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.
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.
| Outcome | What it means | Charged |
|---|---|---|
ok | A live registration was returned | 1 credit |
not_found | Well-formed, but no such registration exists | Free |
invalid_format | Rejected offline — never sent to the GST network | Free |
upstream_error | The GST network failed, after a retry | Free |
skipped | Not reached — the job was cancelled or ran out of credit | Free |
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,failedandskippedare 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.
| Stage | Count | Charged |
|---|---|---|
| Rows submitted | 5,000 | — |
| Exact repeats, collapsed | 300 | Free |
| Distinct numbers to resolve | 4,700 | — |
| Malformed — rejected offline | 150 | Free |
| Sent to the GST network | 4,550 | — |
| Not registered | 90 | Free |
| Upstream error after retry | 15 | Free |
| Verified successfully | 4,445 | 4,445 credits |
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
| Limit | Value |
|---|---|
| GSTINs per job | 10,000 |
| Unfinished jobs per account | 3 |
| Result rows per page | 1,000 |
| Job submissions | 20 per minute |
| Status polls | 600 per minute |
| Result and listing calls | 120 per minute |
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?
| Situation | Use |
|---|---|
| Verifying one vendor during onboarding, user is waiting | Single lookup |
| A handful of numbers in an ad-hoc script | Single lookup, in a loop |
| A vendor master, customer list or procurement export | Batch job |
| A nightly re-check of everyone you pay | Batch job |
| A list with unknown quality and probable duplicates | 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
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 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 gst verification & search
Browse every guide in the GST API blog.