Integration guide 8 min read

How to Integrate a GST Verification API in Go (Golang)

A Go tutorial showing how to call a GST verification API, parse JSON responses, and handle errors idiomatically.

Written and reviewed by the GSTIN API team Last reviewed

To call a GST verification API in Go, build a small struct-based client around net/http, pass a context.Context into every request for cancellation and timeouts, and decode the JSON response into typed structs with encoding/json. This gets you a GSTIN lookup that is idiomatic Go: explicit errors, no hidden goroutines, and no dependency beyond the standard library.

A struct-based client

Rather than writing a bare function that calls the API, wrap the base URL, API key, and HTTP client in a struct. This is the idiomatic Go equivalent of a service class: it gives you one place to configure timeouts, swap in a mock http.Client for tests, and add retry logic later without touching call sites.

type Client struct {
    baseURL    string
    apiKey     string
    httpClient *http.Client
}

func NewClient(apiKey string) *Client {
    return &Client{
        baseURL: "https://gstinapi.com/api",
        apiKey:  apiKey,
        httpClient: &http.Client{
            Timeout: 10 * time.Second,
        },
    }
}

Keep the API key out of source control — read it from an environment variable at startup rather than hardcoding it into the struct literal, and never log the full X-API-Key header value.

Timeouts with context.Context

Every exported method on the client should take a context.Context as its first argument. That is not boilerplate for its own sake — it is what lets a caller enforce a request-scoped deadline, cancel an in-flight lookup when an HTTP handler's own request is cancelled, or apply a shorter timeout during a bulk job than during an interactive form submission.

func (c *Client) GetTaxpayerInfo(ctx context.Context, gstin string) (*TaxpayerInfo, error) {
    url := fmt.Sprintf("%s/get-taxpayer-info/%s", c.baseURL, gstin)

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    req.Header.Set("X-API-Key", c.apiKey)

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("calling gstinapi.com: %w", err)
    }
    defer resp.Body.Close()

    return c.handleResponse(resp)
}

Using http.NewRequestWithContext instead of the older http.NewRequest is what wires the context into the underlying transport, so a cancelled context actually aborts the socket read rather than just being ignored.

Explicit error values and errors.Is

Idiomatic Go avoids returning generic errors that force callers to parse a string. Define sentinel errors for the outcomes your callers actually need to branch on — a GSTIN that does not exist, an invalid API key, and a rate limit — and wrap the underlying cause with %w so context is preserved without hiding the sentinel from errors.Is.

var (
    ErrGstinNotFound = errors.New("gstin not found")
    ErrUnauthorized  = errors.New("invalid or missing api key")
    ErrRateLimited   = errors.New("rate limit exceeded")
)

func (c *Client) handleResponse(resp *http.Response) (*TaxpayerInfo, error) {
    switch resp.StatusCode {
    case http.StatusOK:
        var info TaxpayerInfo
        if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
            return nil, fmt.Errorf("decoding response: %w", err)
        }
        return &info, nil
    case http.StatusNotFound:
        return nil, ErrGstinNotFound
    case http.StatusUnauthorized:
        return nil, ErrUnauthorized
    case http.StatusTooManyRequests:
        return nil, ErrRateLimited
    default:
        return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
    }
}

Callers then branch cleanly, without ever inspecting an HTTP status code themselves:

info, err := client.GetTaxpayerInfo(ctx, gstin)
if errors.Is(err, gstapi.ErrGstinNotFound) {
    // treat as "not registered", not a system failure
}

Modeling the JSON response

Model the response as a struct with explicit json tags rather than decoding into map[string]interface{}. The live response for an active taxpayer includes legal name, trade name, registration status, constitution, taxpayer type, jurisdiction, e-invoicing status, principal and additional places of business, and full GSTR filing history — see the API documentation for the complete field list. A trimmed struct covering the fields most integrations need looks like this:

type TaxpayerInfo struct {
    Gstin            string     `json:"gstin"`
    LegalName        string     `json:"legal_name"`
    TradeName        string     `json:"trade_name"`
    Status           string     `json:"status"`
    RegistrationDate string     `json:"registration_date"`
    Constitution     string     `json:"constitution"`
    TaxpayerType     string     `json:"taxpayer_type"`
    FilingHistory    []Filing   `json:"filing_history"`
}

type Filing struct {
    ReturnType string `json:"return_type"`
    Period     string `json:"return_period"`
    Arn        string `json:"arn"`
    Status     string `json:"status"`
}

A real GSTIN like 27AAACR5055K1Z7 (a large Maharashtra taxpayer) can return several hundred filing records and well over a hundred additional places of business, so decode FilingHistory and similar arrays lazily if you only need a summary — don't hold the full slice in memory across a bulk run if you can avoid it.

Bulk verification with a worker pool

For a one-off GSTIN check, a single call is fine. For verifying hundreds of vendor GSTINs — the common case in an accounts-payable or vendor onboarding workflow — spin up a bounded worker pool: a fixed number of goroutines pulling from a channel of GSTINs, writing results to a second channel. Bounding the pool size keeps you inside the API's rate limit instead of firing hundreds of goroutines at once.

func VerifyBulk(ctx context.Context, client *Client, gstins []string, workers int) []Result {
    jobs := make(chan string, len(gstins))
    results := make(chan Result, len(gstins))

    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for gstin := range jobs {
                info, err := client.GetTaxpayerInfo(ctx, gstin)
                results <- Result{Gstin: gstin, Info: info, Err: err}
            }
        }()
    }

    for _, g := range gstins {
        jobs <- g
    }
    close(jobs)

    go func() {
        wg.Wait()
        close(results)
    }()

    var out []Result
    for r := range results {
        out = append(out, r)
    }
    return out
}

A worker count of 5–10 is a reasonable starting point for bulk GSTIN checks; tune it against your account's actual rate limit rather than guessing. See our rate limits guide for current limits, and bulk verification patterns for how to structure the surrounding job (queueing, retries, and result storage) beyond just the Go concurrency piece.

A GSTIN's final character is a checksum over the first fourteen — a Luhn mod-36 style algorithm — so a well-formed string can still be entirely fake. Validating the checksum client-side (worth doing before you even make the network call, to fail fast on typos) tells you the format is right; it does not tell you the business is registered or Active. Only a live lookup through the GST verification API answers that. For the format details, see GSTIN format explained.

Frequently asked questions

Use net/http's default client with a custom http.Client that sets a Timeout, pass a context.Context down through every request so callers can cancel or bound the call, and decode the response body directly into a typed struct with encoding/json. Avoid the zero-value http.Client with no timeout in production code.
Define a sentinel error such as ErrGstinNotFound and return it wrapped with fmt.Errorf and %w, so callers can check it with errors.Is instead of parsing strings or matching on HTTP status codes scattered through the codebase.
Yes. A bounded worker pool built from goroutines and a buffered channel lets you fan out lookups across a fixed number of workers, which respects the API rate limit and avoids opening hundreds of simultaneous connections at once.
No. The standard library alone (net/http, encoding/json, context) is enough to build a production-ready client for this API. A library only starts to earn its keep if you want built-in retry/backoff or OpenTelemetry instrumentation.

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