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.
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
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 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 developer integration guides
Browse every guide in the GST API blog.