Integration guide 7 min read

How to Integrate a GST Verification API in Laravel

Add real-time GSTIN verification to a Laravel app with a dedicated service class, Laravel’s HTTP client, and a testable interface.

Written and reviewed by the GSTIN API team Last reviewed

To integrate a GST verification API in Laravel you need three things: the key in config, a small service class wrapping the framework’s HTTP client, and an interface so the rest of your app never knows the difference between a real lookup and a fake one in a test. No package required — Laravel already ships everything.

This guide builds that up properly: local checksum validation so you stop paying for typos, a custom validation rule so a GSTIN field validates like any other, caching, queued bulk verification, and tests.

Store the key in config, never in code

Put the credential in .env and read it through a config file, so config:cache works in production and the key never reaches your repository.

# .env
GSTIN_API_KEY=your_api_key_here
GSTIN_API_BASE_URL=https://gstinapi.com/api

Then expose it in config/services.php:

'gstin' => [
    'key' => env('GSTIN_API_KEY'),
    'base_url' => env('GSTIN_API_BASE_URL', 'https://gstinapi.com/api'),
    'timeout' => (int) env('GSTIN_API_TIMEOUT', 15),
    'cache_ttl' => (int) env('GSTIN_API_CACHE_TTL', 6 * 3600),
],

Validate the GSTIN before you spend a credit

A GSTIN is 15 characters: a 2-digit state code, the 10-character PAN of the entity, an entity code, a literal Z, and a check digit computed from the first 14 with a Luhn mod-36 style algorithm. If that check digit does not agree, the number cannot be a real registration — so there is no point sending it anywhere.

<?php

namespace App\Support;

class Gstin
{
    private const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';

    private const PATTERN = '/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/';

    public static function normalise(?string $value): string
    {
        return strtoupper(preg_replace('/[^0-9A-Za-z]/', '', (string) $value) ?? '');
    }

    public static function isValid(?string $value): bool
    {
        $gstin = self::normalise($value);

        if (! preg_match(self::PATTERN, $gstin)) {
            return false;
        }

        return self::checkDigitFor(substr($gstin, 0, 14)) === $gstin[14];
    }

    public static function checkDigitFor(string $first14): ?string
    {
        if (strlen($first14) < 14) {
            return null;
        }

        $sum = 0;

        for ($i = 0; $i < 14; $i++) {
            $value = strpos(self::CHARSET, $first14[$i]);

            if ($value === false) {
                return null;
            }

            // Weights alternate 1, 2 across the 14 characters.
            $product = $value * (($i % 2 === 0) ? 1 : 2);

            $sum += intdiv($product, 36) + ($product % 36);
        }

        return self::CHARSET[(36 - ($sum % 36)) % 36];
    }
}

You can verify this against a real number: the first 14 characters of 27AAACR5055K1Z7 produce a check digit of 7, which matches. Change any character and it stops matching. See GSTIN format explained for the full character-by-character breakdown.

Wrap the call in a service class

Keep Http:: out of your controllers. One class means one place to set the timeout, one place to map errors, and one thing to swap in a test.

<?php

namespace App\Services;

use App\Support\Gstin;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class GstinVerifier implements GstinVerifierContract
{
    public function verify(string $gstin): GstinResult
    {
        $gstin = Gstin::normalise($gstin);

        if (! Gstin::isValid($gstin)) {
            return GstinResult::invalid($gstin);
        }

        $payload = Cache::remember(
            "gstin:{$gstin}",
            config('services.gstin.cache_ttl'),
            fn () => $this->fetch($gstin),
        );

        return GstinResult::fromPayload($gstin, $payload);
    }

    private function fetch(string $gstin): array
    {
        try {
            $response = Http::withHeaders(['X-API-Key' => config('services.gstin.key')])
                ->timeout(config('services.gstin.timeout'))
                ->retry(2, 300, throw: false)
                ->get(config('services.gstin.base_url')."/get-taxpayer-info/{$gstin}");
        } catch (ConnectionException $e) {
            report($e);

            return [];
        }

        return $response->successful() ? $response->json() : [];
    }
}

retry(2, 300, throw: false) gives you two retries 300ms apart and returns the failed response instead of throwing, so a transient blip does not surface to the user. Note that only successful lookups are cached — caching an outage would turn a momentary failure into hours of wrong answers.

Put an interface in front of it

Http::fake() is convenient, but it tests your HTTP wiring rather than your business logic. An interface lets you swap the whole verifier for a stub when what you actually want to test is what your onboarding flow does with an inactive vendor.

// app/Services/GstinVerifierContract.php
interface GstinVerifierContract
{
    public function verify(string $gstin): GstinResult;
}

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(GstinVerifierContract::class, GstinVerifier::class);
}

Call it from a controller or form request

 public function store(StoreVendorRequest $request, GstinVerifierContract $verifier)
{
    $result = $verifier->verify($request->validated('gstin'));

    if (! $result->isActive()) {
        return back()->withErrors([
            'gstin' => 'This GSTIN is not currently active, so we cannot onboard this vendor.',
        ])->withInput();
    }

    $vendor = Vendor::create([
        ...$request->validated(),
        'legal_name' => $result->legalName,
        'gstin_verified_at' => now(),
    ]);

    return to_route('vendors.show', $vendor);
}

Storing the legal name the government returned — rather than the one the vendor typed — is the single highest value thing you can do with the response. It is what lets you catch a supplier whose invoice name does not match their registration. See GST verification for vendor onboarding.

Make it a validation rule instead

For most Laravel apps the cleanest place for this is a validation rule, so a GSTIN field behaves like every other validated field.

<?php

namespace App\Rules;

use App\Services\GstinVerifierContract;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ActiveGstin implements ValidationRule
{
    public function __construct(private GstinVerifierContract $verifier) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $result = $this->verifier->verify((string) $value);

        if ($result->notFound()) {
            $fail('No GST registration exists for :attribute.');

            return;
        }

        if (! $result->isActive()) {
            $fail('The GST registration for :attribute is :status.')
                ->translate(['status' => strtolower($result->status)]);
        }
    }
}

Then in a form request:

 public function rules(): array
{
    return [
        'gstin' => ['required', 'string', 'size:15', app(ActiveGstin::class)],
    ];
}

Handle failures explicitly

The three outcomes you must distinguish are a GSTIN that does not exist, a GSTIN that exists but is not active, and our own failure to reach the data source. Only the first two say anything about the vendor.

OutcomeWhat it meansWhat to do
Checksum failsImpossible number — a typoReject inline, no API call
Not foundWell-formed but never issuedReject, and treat as a fraud signal
Cancelled or SuspendedReal, but not currently validBlock onboarding, flag for review
ActiveLive registrationProceed, store the legal name
Timeout or 5xxOur problem, not theirsRetry, then fail soft — never reject the vendor
Map each outcome to a distinct user-facing behaviour.

The last row matters most. Rejecting a legitimate vendor because your own provider had a bad thirty seconds is worse than letting them through and re-verifying later. Full list in the API error codes guide.

Cache results and queue the bulk work

For a vendor master, dispatch a batch and let queue concurrency throttle you rather than writing your own rate limiter.

 use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$jobs = Vendor::query()
    ->whereNull('gstin_verified_at')
    ->pluck('gstin')
    ->map(fn (string $gstin) => new VerifyVendorGstin($gstin));

Bus::batch($jobs)
    ->name('Vendor GSTIN sweep')
    ->allowFailures()
    ->then(fn (Batch $batch) => Log::info('Sweep complete', ['total' => $batch->totalJobs]))
    ->dispatch();

Run the worker with a modest --max-jobs and a single queue, and you have a natural ceiling on request rate. For the wider design — chunking, backoff, and the output format — see the bulk GSTIN verification guide and rate limits, retries and webhooks.

Test it without touching the network

Two levels. Fake the transport to test the service:

 Http::fake([
    'gstinapi.com/*' => Http::response([
        'taxpayer_data' => ['status_code' => 1, 'status' => 'Active', 'name' => 'ACME PRIVATE LIMITED'],
    ]),
]);

$result = app(GstinVerifierContract::class)->verify('27AAACR5055K1Z7');

expect($result->isActive())->toBeTrue()
    ->and($result->legalName)->toBe('ACME PRIVATE LIMITED');

And swap the whole service to test what your application does with the answer:

 it('refuses to onboard a vendor whose GSTIN is cancelled', function () {
    $this->mock(GstinVerifierContract::class)
        ->shouldReceive('verify')
        ->andReturn(GstinResult::cancelled('27AAACR5055K1Z7'));

    $this->post('/vendors', ['gstin' => '27AAACR5055K1Z7', 'name' => 'ACME'])
        ->assertSessionHasErrors('gstin');

    expect(Vendor::count())->toBe(0);
});

Also assert the cheap path never calls out at all — that a malformed GSTIN costs nothing is a property worth protecting:

 it('never calls the provider for a checksum-invalid GSTIN', function () {
    Http::fake();

    app(GstinVerifierContract::class)->verify('27AAACR5055K1Z8');

    Http::assertNothingSent();
});

That is the whole integration: config, local validation, a service behind an interface, explicit error handling, and tests at both levels. Working in another stack? The same shape applies in Python, plain PHP, and Node.js. The full request and response reference is in the API documentation.

Frequently asked questions

No. Laravel ships with an HTTP client built on Guzzle, and that is all you need. A dedicated package adds a dependency and an upgrade burden for what amounts to one GET request with one header.
In a service class under app/Services, bound to an interface in a service provider. Controllers should ask for the interface and never touch the HTTP facade directly — that keeps the integration in one place and makes it trivial to fake in tests.
No. Registration status changes rarely, so cache each result for a few hours and re-verify on a schedule rather than on every page load. Verify live at the moments that matter: onboarding a vendor, and before releasing a payment.
Check the format and the check digit locally before calling the API. A GSTIN whose checksum does not match can never be real, so there is no reason to spend a request on it. The algorithm is a few lines of PHP and is shown in full below.
Dispatch a queued job per GSTIN, or a batch job per chunk, and let the queue worker concurrency act as your rate limit. Laravel’s Bus::batch gives you progress tracking and a completion callback for free.

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