Early in my time working on a payment processing service, we had a bug that cost us real money. A merchant integration was sending payment requests with negative amounts. Our API happily decoded the JSON, persisted it, and the downstream settlement engine treated those negative charges as refunds. By the time anyone noticed, we'd issued a few thousand dollars in bogus refunds. The fix was embarrassingly simple — a single validation rule on a struct field. But the lesson stuck: if you're not validating inbound data at the struct level in Go, you're trusting every upstream caller to be perfect. They won't be.
This article walks through the validation patterns we settled on after that incident. Nothing theoretical — just what actually works when you're processing payment requests in Go and you need to be sure the data is clean before it touches your database.
The Validation Pipeline
Before diving into code, here's the mental model. Every inbound payment request passes through a pipeline, and validation is a distinct, non-negotiable stage:
The critical insight: encoding/json will happily decode a negative number into an int64 field. It will decode "currency": "ZZZZ" without complaint. JSON decoding is not validation. You need a separate step, and in Go, struct tags combined with a validation library give you the cleanest way to express those rules right next to the fields they protect.
Struct Tags and go-playground/validator
The go-playground/validator library is the de facto standard for struct validation in Go. It lets you declare rules as struct tags, which keeps validation logic co-located with your data definition. Here's what a real payment request struct looks like:
type PaymentRequest struct {
MerchantID string `json:"merchant_id" validate:"required,uuid4"`
Amount int64 `json:"amount" validate:"required,gt=0"`
Currency string `json:"currency" validate:"required,iso4217"`
CardNumber string `json:"card_number" validate:"required,credit_card"`
ExpiryMonth int `json:"expiry_month" validate:"required,min=1,max=12"`
ExpiryYear int `json:"expiry_year" validate:"required,min=2026"`
Reference string `json:"reference" validate:"required,min=1,max=64"`
}
The gt=0 on Amount is the one-line fix that would have prevented our negative-amount bug. The iso4217 tag validates currency codes against the actual ISO 4217 list. The credit_card tag runs a Luhn check. All of this happens before your handler function even starts doing real work.
Wiring it up is straightforward:
import "github.com/go-playground/validator/v10"
var validate = validator.New()
func validatePayment(req *PaymentRequest) error {
if err := validate.Struct(req); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
Writing Custom Validators
The built-in tags cover a lot, but payment systems always have domain-specific rules. We needed to validate that currency codes matched the subset we actually support (not every ISO 4217 currency — we don't process transactions in Bhutanese Ngultrum). And we wanted tighter card number validation that checked BIN ranges for our supported networks.
var supportedCurrencies = map[string]bool{
"USD": true, "EUR": true, "GBP": true,
"SGD": true, "JPY": true, "AUD": true,
}
func supportedCurrencyValidator(fl validator.FieldLevel) bool {
return supportedCurrencies[fl.Field().String()]
}
// Register it once at startup
validate.RegisterValidation("supported_currency", supportedCurrencyValidator)
For the Luhn check, while credit_card handles the basics, we added a custom validator that also rejects test card numbers in production:
func productionCardValidator(fl validator.FieldLevel) bool {
num := fl.Field().String()
// Reject common test card prefixes in production
testPrefixes := []string{"4111111111", "5500000000", "3782822463"}
for _, prefix := range testPrefixes {
if strings.HasPrefix(num, prefix) {
return false
}
}
return luhnCheck(num)
}
func luhnCheck(number string) bool {
sum := 0
alt := false
for i := len(number) - 1; i >= 0; i-- {
n := int(number[i] - '0')
if alt {
n *= 2
if n > 9 { n -= 9 }
}
sum += n
alt = !alt
}
return sum%10 == 0
}
Validation Middleware Pattern
Scattering validate.Struct() calls across every handler gets messy fast. We moved validation into middleware so every endpoint gets it automatically. The pattern looks like this:
func ValidationMiddleware[T any](next func(w http.ResponseWriter, r *http.Request, req T)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req T
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if err := validate.Struct(req); err != nil {
errs := translateValidationErrors(err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]any{"errors": errs})
return
}
next(w, r, req)
}
}
This gives you a clean separation: the middleware handles decoding and validation, and your handler only ever sees data that's already been verified. The translateValidationErrors function maps field-level errors into API-friendly messages — you don't want to leak struct field names or internal tag syntax to callers.
Comparing Validation Approaches
We evaluated a few strategies before settling on struct tags. Here's how they stack up:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Manual if/else checks | No dependencies, full control | Verbose, easy to miss fields, hard to maintain | Tiny projects with 1-2 endpoints |
| Struct tags (go-playground/validator) | Declarative, co-located with struct, extensible | Learning curve for tag syntax, reflection overhead | Most production APIs |
| JSON Schema validation | Language-agnostic, shareable with frontend | Separate schema file to maintain, less Go-idiomatic | Public APIs with external consumers |
| Protobuf + protovalidate | Strong typing, cross-language, built into gRPC | Requires protobuf toolchain, overkill for REST | gRPC-first microservices |
For REST-based payment APIs, struct tags hit the sweet spot. The validation rules live right next to the fields, so when someone reads the struct definition, they immediately understand the constraints. No hunting through separate validation files.
The Negative Amount Bug — What Actually Happened
Let me give more detail on that incident I mentioned. A merchant's integration had a bug in their amount calculation that occasionally produced negative values. Our API accepted the request, stored it, and the settlement engine interpreted negative amounts as refunds. The merchant effectively got free money back on transactions they'd already been paid for.
The root cause wasn't malicious — it was a signed integer overflow in the merchant's PHP code. But the impact was the same as if it had been an attack. After the fix, we audited every struct in our payment ingestion path and added validation tags to all of them. We also added a monitoring alert for any request that fails validation with a negative amount, which has caught two more integration bugs from other merchants since.
Key takeaway: Validation isn't just about rejecting bad actors. Most of the garbage data you'll see in production comes from bugs in legitimate integrations. Your validation layer is the last line of defense before bad data becomes a financial incident. Treat every inbound struct field as untrusted, even from partners you've worked with for years.
Practical Tips From Production
A few things we learned the hard way:
- Always validate after decoding, never before. Validating raw JSON strings is fragile and duplicates work that
encoding/jsonalready does. - Use
int64for monetary amounts (cents/minor units), neverfloat64. Floating point and money don't mix. The validator'sgt=0tag works cleanly with integers. - Log validation failures with the merchant ID and request ID, but never log the full card number. We log the last four digits and the validation error — enough to debug, not enough to create a PCI incident.
- Return structured error responses. A 422 with
{"errors": [{"field": "amount", "message": "must be greater than zero"}]}saves merchants hours of debugging compared to a generic 400. - Run the validator singleton — create one
validator.New()instance and reuse it. It's safe for concurrent use and avoids repeated reflection setup.
Struct validation in Go is one of those things that feels like overhead until it saves you from a real incident. After that negative-amount bug, we made it a rule: no payment struct gets persisted without passing through the validator. It's a small investment that pays for itself the first time it catches something.
References
- go-playground/validator — GitHub Repository
- Go encoding/json — Official Documentation
- OWASP Input Validation Cheat Sheet
- go-playground/validator v10 — pkg.go.dev
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.