Last year, our payment platform hit a wall. The original API — designed three years prior when we had 12 merchants — was buckling under the weight of features it was never meant to support. Multi-currency settlements, split payments, 3DS2 authentication flows — all bolted on through increasingly creative parameter overloading. We needed a clean v2, but 200 merchants were actively processing transactions on v1. Downtime or broken integrations meant real revenue loss for real businesses.
This is the story of how we pulled it off.
Choosing a Versioning Strategy
Before writing any code, we spent two weeks evaluating versioning approaches. Every strategy has trade-offs, and in payments, the wrong choice compounds over years. Here's what we considered:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL Path | /v1/charges |
Explicit, easy to route, cache-friendly | URL proliferation, hard to do partial upgrades |
| Header | Api-Version: 2 |
Clean URLs, per-request flexibility | Easy to forget, harder to debug, invisible in logs |
| Query Param | ?version=2 |
Simple to test in browser | Pollutes query string, caching headaches |
| Date-based ✓ | Stripe-Version: 2025-06-15 |
Granular, self-documenting, incremental changes | More versions to maintain, needs strong tooling |
We went with a hybrid: URL path versioning for major structural changes (/v1/ vs /v2/) combined with date-based versioning for minor breaking changes within a major version. This is essentially what Stripe does, and for good reason — it lets you ship incremental improvements without forcing merchants onto an entirely new API surface.
Why we studied Stripe's approach: Stripe pins each API key to the version that was current when the key was created. Merchants opt into newer versions explicitly. This means a merchant who integrated two years ago still gets the response shapes they coded against, even if the API has evolved 30 times since. That's the gold standard for payment APIs.
The Backward Compatibility Contract
Before building anything, we wrote down our compatibility contract. This sounds bureaucratic, but it saved us from at least a dozen arguments later. The rules were simple:
- Adding new fields to a JSON response is never a breaking change
- Removing or renaming a field is always a breaking change
- Changing a field's type (string to integer, etc.) is a breaking change
- Adding a new required request parameter is a breaking change
- New optional request parameters are not breaking changes
- Changing error code formats is a breaking change
We encoded these rules into a CI check that compared OpenAPI specs between versions. Any PR that violated the contract for the current stable version got flagged automatically. No human review needed for the obvious stuff.
Version Negotiation Middleware in Go
The core of our system is a Go middleware that resolves which API version to use for each request. It checks three places in order: the URL path, a custom header, and finally the version pinned to the merchant's API key.
/v1/ or /v2/
X-Api-Version
pinned version
Here's the actual middleware (simplified, but close to what we run in production):
func VersionMiddleware(keyStore KeyStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var version string
// 1. Check URL path prefix
if strings.HasPrefix(r.URL.Path, "/v2/") {
version = "v2"
} else if strings.HasPrefix(r.URL.Path, "/v1/") {
version = "v1"
}
// 2. Header override (within the major version)
if hdr := r.Header.Get("X-Api-Version"); hdr != "" {
if err := validateVersion(hdr); err == nil {
version = hdr
}
}
// 3. Fall back to the version pinned to the API key
if version == "" {
apiKey := extractAPIKey(r)
if pinned, err := keyStore.GetPinnedVersion(r.Context(), apiKey); err == nil {
version = pinned
}
}
// Default to latest stable if nothing matched
if version == "" {
version = CurrentStableVersion
}
// Inject into context and set response header
ctx := context.WithValue(r.Context(), versionCtxKey, version)
w.Header().Set("X-Api-Version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
The key insight: we always echo the resolved version back in the response header. This made debugging trivial. When a merchant reported unexpected behavior, the first thing support checked was which version the response was actually served under.
Response Transformation
Rather than maintaining entirely separate handler code for each version, we built response transformers. The handler always produces the latest response shape, and a chain of transformers downgrades it for older versions:
// Each transformer knows how to convert from version N to N-1
type ResponseTransformer func(body map[string]interface{}) map[string]interface{}
var transformers = map[string]ResponseTransformer{
"v2:2025-09-01": func(body map[string]interface{}) map[string]interface{} {
// v2:2025-09-01 renamed "payment_method" to "source"
if pm, ok := body["payment_method"]; ok {
body["source"] = pm
delete(body, "payment_method")
}
return body
},
// ... more transformers
}
This pattern meant new features only needed to be built once against the latest version. The transformers handled backward compatibility mechanically.
The 14-Month Deprecation Lifecycle
Rushing merchants off an old API version is a recipe for churn. We designed a deprecation timeline that gave everyone plenty of runway while still keeping us moving forward.
Sunset header to v1 responses.Deprecation header with target date. Weekly migration reports to merchant success team.410 Gone with a JSON body pointing to v2 equivalents. By this point, all 200 merchants had migrated voluntarily.Lesson learned: The Sunset and Deprecation HTTP headers (RFC 8594 and the draft RFC) are machine-readable. Some of our merchants' monitoring tools picked them up automatically and created tickets. Free migration nudges without any extra work on our side.
Migration Tooling and Changelog Automation
We built two tools that made the migration dramatically smoother:
1. The Compatibility Checker
A CLI tool merchants could run against their codebase. It parsed their API calls, compared request/response shapes against the v2 spec, and produced a diff report. Think of it as a linter for API compatibility. We open-sourced it, and a few merchants even contributed fixes back.
2. Automated Changelogs
Every PR that touched an API endpoint required a changelog entry in a structured YAML format. Our CI pipeline aggregated these into a per-version changelog that was published automatically to our developer portal. No more "check the release notes" emails that nobody reads — the changelog was always current and always complete.
# changelog/2025-09-01.yaml
version: "2025-09-01"
changes:
- type: breaking
endpoint: POST /v2/charges
description: "Field 'source' renamed to 'payment_method'"
migration: "Replace 'source' with 'payment_method' in request body"
- type: addition
endpoint: GET /v2/charges/{id}
description: "Added 'payment_method_details' object to response"
What I'd Do Differently
If I were starting over, I'd invest in contract testing earlier. We caught most issues through integration tests, but a tool like Pact would have caught version mismatches at the unit test level. I'd also version webhooks from day one — we didn't, and retrofitting versioned webhook payloads was the most painful part of the entire project.
API versioning in payments isn't glamorous work. There's no conference talk that makes deprecation timelines sound exciting. But when you're handling someone else's money, the boring, methodical approach is exactly what earns trust. Our merchants didn't notice the migration because that was the whole point — the best infrastructure is the kind nobody has to think about.
References
- Stripe API Versioning Documentation — The gold standard for date-based API versioning in payments
- Stripe API Upgrades Guide — How Stripe communicates breaking changes and migration paths
- RFC 8594 — The Sunset HTTP Header Field — Standard for signaling API deprecation via HTTP headers
- Microsoft REST API Versioning Guidelines — Comprehensive overview of versioning strategies and trade-offs
- Pact Contract Testing Documentation — Consumer-driven contract testing for API compatibility
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.