Go SDK
The official Go client for the StampMitra Developer API: identity verification and e-Stamp with context-aware calls, typed responses, errors.Is-friendly errors, automatic retries and signed-webhook helpers, using only the standard library.
Pre-release 0.1.0 - package registry publishing in progress
- Package
github.com/Bani-Global-Industries-LLP/stampmitra-developer-go- Version
- 0.1.0
- Requires
- Go 1.21 or newer (CI runs 1.21 to 1.24). The standard library only: no third-party dependencies. The package name is stampmitra, and the module path is case-sensitive, so import it with the capital letters exactly as shown.
- License
- MIT
- Changelog
- CHANGELOG.md
- Standard library only; every call takes a context.Context and per-call options.
- Responses keep unknown fields in Extra, so nothing the API sends is lost.
- Retries with jittered backoff and an automatic Idempotency-Key on every POST except webhook replay.
- Lazy log iterator, typed webhook events, and redacted debug logging through log/slog.
Install the package
go get github.com/Bani-Global-Industries-LLP/[email protected]Available once published. Not yet published to the Go module proxy. This command works once the package is released; until then follow the GitHub repository for the release.
Verify a PAN in five lines
Use a sandbox key (sk_test_...) while you build. It is free, never debits your wallet and runs against sandbox data.
import stampmitra "github.com/Bani-Global-Industries-LLP/stampmitra-developer-go"
client, err := stampmitra.NewClient("") // empty = read STAMPMITRA_API_KEY
if err != nil { log.Fatal(err) }
pan, err := client.Verify.PAN(context.Background(), &stampmitra.PANVerifyParams{PAN: "ABCDE1234F", Name: "Rahul Kumar"})
if err != nil { log.Fatal(err) }
fmt.Println(pan.Valid, pan.NameMatchResult, pan.ReferenceID)API keys and environments
Sandbox
Sandbox keys start with sk_test_. They are free and return sandbox data.
Live
Live keys start with sk_live_. They need an approved production access request and are metered per successful call.
NewClient("") reads the STAMPMITRA_API_KEY environment variable. Keys that do not match ^sk_(test|live)_[A-Za-z0-9_-]{8,}$ are rejected unless you pass WithAllowCustomKey(). client.Environment is "test", "live" or "custom". The base URL must be https (plain http only for localhost, 127.0.0.1 and ::1), with no credentials and no query. The API key never appears in fmt output of the client (%v, %+v, %#v), errors or logs.
client, err := stampmitra.NewClient(key,
stampmitra.WithBaseURL("https://api-b2b.stampmitra.in"),
stampmitra.WithTimeout(30*time.Second), // per attempt
stampmitra.WithMaxRetries(2),
stampmitra.WithBackoff(500*time.Millisecond, 8*time.Second),
stampmitra.WithHTTPClient(httpClient), // redirects are still never followed
stampmitra.WithDefaultHeader("X-Trace", "abc"),
stampmitra.WithAppInfo(stampmitra.AppInfo{Name: "my-app", Version: "1.0.0"}),
stampmitra.WithLogger(slog.Default()),
stampmitra.WithValidateInput(true),
stampmitra.WithAllowCustomKey(),
)
// Every method takes a context.Context first and per-call options last:
// WithRequestTimeout, WithRequestMaxRetries, WithIdempotencyKey, WithRequestHeader, WithRawResponseEvery resource in one place
Responses are structs with json tags (pan.RegisteredName). Every response struct has an Extra map[string]json.RawMessage for fields this SDK version does not model, and json.Marshal writes them back. Endpoints whose shape the API does not fix (e-Stamp reference data, duty, parties, webhook event lists and replays) return *GenericResponse (Raw, Decode, Get, Map, Items, NextCursor). Orders are addressed by the externalOrderId that Create returns (there is no id).
ctx := context.Background()
// Verification (every call, including valid=false results, is billed)
pan, err := client.Verify.PAN(ctx, &stampmitra.PANVerifyParams{PAN: "ABCDE1234F", Name: "Rahul Kumar"})
gst, err := client.Verify.GSTIN(ctx, &stampmitra.GSTINVerifyParams{GSTIN: "27ABCDE1234F1Z5"})
rc, err := client.Verify.RC(ctx, &stampmitra.RCVerifyParams{VehicleNumber: "MH12AB1234"})
voter, err := client.Verify.VoterID(ctx, &stampmitra.VoterIDVerifyParams{VoterID: "ABC1234567"})
dl, err := client.Verify.DrivingLicense(ctx, &stampmitra.DrivingLicenseVerifyParams{DLNumber: "MH1420110012345", DOB: "1990-01-31"}) // IP-allowlisted key
pp, err := client.Verify.Passport(ctx, &stampmitra.PassportVerifyParams{FileNumber: "PA1079341954215", DOB: "1990-01-31"}) // IP-allowlisted key
cin, err := client.Verify.CIN(ctx, &stampmitra.CINVerifyParams{CIN: "U74999DL2015PTC284009"})
udyam, err := client.Verify.Udyam(ctx, &stampmitra.UdyamVerifyParams{UdyamNumber: "UDYAM-MH-12-1234567"})
gstins, err := client.Verify.PANToGSTIN(ctx, &stampmitra.PANToGSTINParams{PAN: "ABCDE1234F"})
udyams, err := client.Verify.PANToUdyam(ctx, &stampmitra.PANToUdyamParams{PAN: "ABCDE1234F"})
bank, err := client.Verify.BankAccount(ctx, &stampmitra.BankAccountVerifyParams{AccountNumber: "000123456789", IFSC: "ICIC0001234", Name: "Rahul Kumar"})
// Services and e-Stamp reference data
services, err := client.Services.List(ctx)
service, err := client.Services.Get(ctx, "estamp.instrument.rent_agreement")
schema, err := client.Estamp.GetSchema(ctx, "estamp.instrument.rent_agreement")
states, err := client.Estamp.ListStates(ctx)
instruments, err := client.Estamp.ListInstruments(ctx, "MH")
articles, err := client.Estamp.ListArticles(ctx, "rent_agreement")
duty, err := client.Estamp.CalculateDuty(ctx, &stampmitra.EstampDutyParams{StateCode: "MH", ArticleCode: "5", StampDutyAmount: 500})
parties, err := client.Estamp.AddParties(ctx, "ord_ext_123", &stampmitra.EstampPartiesParams{CustomerName: "A", PartyName: "B"})
// Orders and payments: orders are addressed by YOUR externalOrderId
order, err := client.Orders.Create(ctx, &stampmitra.CreateOrderParams{
ExternalOrderID: "ord_ext_123", ExternalCustomerID: "cust_ext_456", ServiceCode: "estamp.instrument.rent_agreement",
}, stampmitra.WithIdempotencyKey("create-ord_ext_123"))
detail, err := client.Orders.Get(ctx, order.ExternalOrderID)
status, err := client.Orders.GetStatus(ctx, order.ExternalOrderID)
payment, err := client.Orders.GetPayment(ctx, order.ExternalOrderID)
docs, err := client.Orders.ListDocuments(ctx, order.ExternalOrderID)
link, err := client.Orders.GetDocumentDownload(ctx, order.ExternalOrderID, "doc_1") // link.DownloadURL is short-lived
intent, err := client.Payments.Intents.Create(ctx, &stampmitra.CreatePaymentIntentParams{ExternalOrderID: "ord_ext_123", AmountMinor: 50000, Currency: "INR", PaymentConnectionID: "pc_1"})
intent, err = client.Payments.Intents.Get(ctx, intent.ID)
// Usage, logs, webhook deliveries
usage, err := client.Usage.Summary(ctx)
page, err := client.Logs.List(ctx, &stampmitra.LogsListParams{Service: "pan.verify", Status: "success", Limit: 50})
events, err := client.Webhooks.Events.List(ctx, &stampmitra.WebhookEventsListParams{Limit: 20})
replay, err := client.Webhooks.Events.Replay(ctx, "evt_1")
// Raw HTTP metadata
var raw stampmitra.Response
st, err := client.Orders.GetStatus(ctx, "ord_ext_123", stampmitra.WithRawResponse(&raw))
fmt.Println(st != nil, err == nil, raw.StatusCode, raw.RequestID, raw.Attempts)Pagination
ListAll performs no I/O itself; Next fetches pages lazily and follows nextCursor. For one page use Logs.List. Webhooks.Events.List returns a *GenericResponse with Items() and NextCursor().
it := client.Logs.ListAll(&stampmitra.LogsListParams{Service: "pan.verify", Limit: 100})
for it.Next(ctx) {
entry := it.Log()
fmt.Println(entry.ID, entry.CreatedAt)
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
page, err := client.Logs.List(ctx, &stampmitra.LogsListParams{Limit: 50}) // page.Items, page.NextCursorError classes
Every non-2xx response is an *stampmitra.APIError with Status, Code, Message, RequestID, Field, RetryAfter, Body and Attempts. Classify it with errors.Is against the sentinels, or with the Is* helpers. Other statuses are still an *APIError without a sentinel, and non-JSON error bodies map by status. A cancelled or expired context returns the context's own error, so errors.Is(err, context.Canceled) works.
| HTTP status | Class | Notes |
|---|---|---|
| 400 / local validation | ErrValidation / IsValidation | Bad input. Also raised locally, before any request, by the validation helpers. |
| 401 | ErrAuthentication / IsAuthentication | Missing, malformed or revoked API key. |
| 402 | ErrPaymentRequired / IsPaymentRequired | Insufficient wallet balance or credit. |
| 403 | ErrPermission / IsPermission | The server message is passed through verbatim, e.g. Passport and Driving Licence need an IP-allowlisted key. |
| 404 | ErrNotFound / IsNotFound | Unknown order, service or event id. |
| 409 | ErrConflict / IsConflict | Idempotency-Key reused with a different body, or a state conflict. |
| 429 | ErrRateLimit / IsRateLimit | Back off and retry; the server's Retry-After value is exposed when it is sent. |
| 5xx | ErrUpstream / IsUpstream | A server-side or upstream failure. Retried automatically for 502, 503 and 504. |
| network / timeout | *ConnectionError (ErrConnection, ErrTimeout) / IsConnection, IsTimeout | No usable response: connection failure, timeout or cancellation. |
| webhook verification | *SignatureVerificationError | Bad or stale webhook signature, an empty secret, or a malformed timestamp. Reject the delivery with a 400. |
_, err := client.Verify.GSTIN(ctx, &stampmitra.GSTINVerifyParams{GSTIN: "27ABCDE1234F1Z5"})
var apiErr *stampmitra.APIError
switch {
case err == nil:
// success
case errors.As(err, &apiErr) && stampmitra.IsRateLimit(err):
log.Println("rate limited; retry after", apiErr.RetryAfter) // zero when the API sent no Retry-After
case errors.As(err, &apiErr):
log.Printf("%d %s (request %s): %s", apiErr.Status, apiErr.Code, apiErr.RequestID, apiErr.Message)
case stampmitra.IsValidation(err):
log.Println("invalid input, nothing was sent:", err)
default:
log.Println("request failed:", err)
}Retries and idempotency
- Default WithMaxRetries(2), with exponential backoff and full jitter (500 ms base, 8 s cap). A Retry-After header (seconds or an HTTP date, capped at 60 s) overrides the computed delay. The timeout (default 30 s) applies per attempt.
- Retried: 429, 502, 503, 504, connection errors and timeouts. Not retried: other statuses (400, 401, 402, 403, 404, 409, 500 and so on). GET is always retry-safe.
- The SDK generates a random UUIDv4 Idempotency-Key (from crypto/rand; it fails rather than fall back to a weak generator) for every POST except webhook replay and reuses the same key across automatic retries. A retried verification, order or payment intent therefore replays the original result instead of being billed or created twice. Pass your own with WithIdempotencyKey.
- A POST without a key (webhook replay) is never retried unless you give it one. Use a key derived from your own order id when your program itself may re-run.
Client-side validation
Inputs are checked before sending (PAN, GSTIN, IFSC, CIN, Udyam, vehicle number, 9 to 18 digit account number, passport file number, driving licence, voter ID, phone, ISO dates, length limits). Values are trimmed and ASCII upper-cased (bank account numbers are only trimmed), and spaces or hyphens inside identifiers are rejected, never silently removed. A failure returns a *ValidationError (it matches ErrValidation) with no network call and no billing, and its message never echoes the value. The validators are exported (ValidatePAN, ValidateGSTIN, ValidateIFSC and so on); turn all of it off with WithValidateInput(false).
Verify webhook signatures
Deliveries carry X-Webhook-Signature, X-Webhook-Timestamp (Unix seconds) and X-Webhook-Event-Id. The signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your endpoint secret. Always verify against the raw body, never re-serialised JSON.
Verification lives in the main package: ConstructEvent, ConstructEventFromHeaders and VerifySignature. The comparison is constant-time, timestamps more than 300 s (WithTolerance) from now are rejected, an empty secret is rejected, and failures return *SignatureVerificationError (Reason: invalid_timestamp, timestamp_outside_tolerance, signature_mismatch and others), which never contains the secret. SignPayload computes a signature for tests.
// body: raw bytes from io.ReadAll(r.Body); do not decode and re-encode
ev, err := stampmitra.ConstructEventFromHeaders(body, r.Header, secret)
if err != nil {
var sigErr *stampmitra.SignatureVerificationError
if errors.As(err, &sigErr) {
log.Println("rejected:", sigErr.Reason)
}
return
}
switch e := ev.(type) {
case *stampmitra.OrderDocumentReadyEvent:
log.Println("document ready for", e.CoreOrderID, e.ID)
case *stampmitra.UnknownEvent:
log.Println("unrecognised event", e.RawType)
}The eight order.* events decode to typed values (*OrderCreatedEvent, *OrderPaymentConfirmedEvent, *OrderFulfilmentStartedEvent, *OrderFulfilmentCompletedEvent, *OrderFailedEvent, *OrderCancelledEvent, *OrderRefundCompletedEvent, *OrderDocumentReadyEvent), all embedding OrderEvent (CoreOrderID, CoreEventType, OccurredAt, ID and Extra). Anything else is an *UnknownEvent. Deliveries are retried, so de-duplicate on the event id from X-Webhook-Event-Id.
Framework recipes
package main
import (
"errors"
"io"
"log"
"net/http"
"os"
stampmitra "github.com/Bani-Global-Industries-LLP/stampmitra-developer-go"
)
const maxBody = 1 << 20 // 1 MiB
func handler(secret string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/webhooks/stampmitra", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Raw bytes: the signature covers the body exactly as received.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
ev, err := stampmitra.ConstructEventFromHeaders(body, r.Header, secret)
if err != nil {
var sigErr *stampmitra.SignatureVerificationError
if errors.As(err, &sigErr) {
log.Printf("rejected webhook: %s", sigErr.Reason) // never log the secret or signature
}
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
switch e := ev.(type) {
case *stampmitra.OrderCreatedEvent:
log.Printf("order created: %s", e.CoreOrderID)
case *stampmitra.OrderPaymentConfirmedEvent:
log.Printf("payment confirmed: %s", e.CoreOrderID)
case *stampmitra.OrderDocumentReadyEvent:
log.Printf("document ready: %s (event %s)", e.CoreOrderID, e.ID)
case *stampmitra.UnknownEvent:
log.Printf("ignoring unrecognised event %q", e.RawType)
}
w.WriteHeader(http.StatusNoContent) // any 2xx acknowledges; a non-2xx is retried
})
return mux
}
func main() {
log.Fatal(http.ListenAndServe(":8080", handler(os.Getenv("WEBHOOK_SECRET"))))
}Webhooks: Event types, payloads and retry policy are on the Webhooks page.
Safe logging
Nothing is logged by default. WithLogger(slog.New(...)) logs requests, responses and retries at slog.LevelDebug. Authorization and X-API-Key are redacted, and identifiers (PAN, GSTIN, account numbers and so on) and personal data (names, dates of birth, addresses) in bodies are masked.
Other ways to integrate
The REST API works from any language, and the other SDKs share the same contract.
Ready to call the API?
Get a free sandbox key and run the quickstart above in about 5 minutes.