April 11, 2026 9 min read

Go Struct Validation for Inbound Payment Requests — Catching Bad Data Before It Hits Your Database

A payment request that looks fine in JSON can wreck your ledger in seconds. Here's how we built a validation layer in Go that rejects garbage at the door — and the real bug that convinced us to take it seriously.

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:

Step 1
HTTP Request
Step 2
JSON Decode
Step 3
Struct Validate
Step 4
Business Logic
Step 5
Persist

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.

$4,200
Bogus refunds issued
3 days
Until detection
1 line
The fix: gt=0

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:

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

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.